@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
|
@@ -0,0 +1,352 @@
|
|
|
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
|
+
|
|
19
|
+
/** Generate `Runtime/Polyfills.cs` for `namespaceName`. Content depends on nothing but the namespace. */
|
|
20
|
+
export function generatePolyfillsCs(namespaceName: string): string {
|
|
21
|
+
return `// <auto-generated/>
|
|
22
|
+
// Generated by @contractkit/plugin-csharp. Do not edit manually.
|
|
23
|
+
#nullable enable
|
|
24
|
+
|
|
25
|
+
#if NETSTANDARD2_0
|
|
26
|
+
|
|
27
|
+
using System;
|
|
28
|
+
using System.Globalization;
|
|
29
|
+
|
|
30
|
+
namespace System.Runtime.CompilerServices
|
|
31
|
+
{
|
|
32
|
+
/// <summary>
|
|
33
|
+
/// The marker the compiler requires before it will emit an <c>init</c> accessor.
|
|
34
|
+
/// </summary>
|
|
35
|
+
internal static class IsExternalInit
|
|
36
|
+
{
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// <summary>
|
|
40
|
+
/// Marks a member whose initialization the compiler requires at every construction site.
|
|
41
|
+
/// </summary>
|
|
42
|
+
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
|
|
43
|
+
internal sealed class RequiredMemberAttribute : Attribute
|
|
44
|
+
{
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// <summary>
|
|
48
|
+
/// Names a compiler feature a member depends on, so a compiler that does not have it refuses the
|
|
49
|
+
/// member rather than misreading it.
|
|
50
|
+
/// </summary>
|
|
51
|
+
[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
|
|
52
|
+
internal sealed class CompilerFeatureRequiredAttribute : Attribute
|
|
53
|
+
{
|
|
54
|
+
public CompilerFeatureRequiredAttribute(string featureName)
|
|
55
|
+
{
|
|
56
|
+
FeatureName = featureName;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public string FeatureName { get; }
|
|
60
|
+
|
|
61
|
+
public bool IsOptional { get; init; }
|
|
62
|
+
|
|
63
|
+
public const string RefStructs = nameof(RefStructs);
|
|
64
|
+
|
|
65
|
+
public const string RequiredMembers = nameof(RequiredMembers);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
namespace System.Diagnostics.CodeAnalysis
|
|
70
|
+
{
|
|
71
|
+
/// <summary>
|
|
72
|
+
/// Marks a constructor that sets every required member itself.
|
|
73
|
+
/// </summary>
|
|
74
|
+
[AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
|
|
75
|
+
internal sealed class SetsRequiredMembersAttribute : Attribute
|
|
76
|
+
{
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
namespace ${namespaceName}.Runtime
|
|
81
|
+
{
|
|
82
|
+
/// <summary>
|
|
83
|
+
/// A calendar date with no time and no offset. Stands in for <c>System.DateOnly</c>.
|
|
84
|
+
/// </summary>
|
|
85
|
+
/// <remarks>
|
|
86
|
+
/// Carries what a generated SDK and the code around it need: construction, ordering, conversion
|
|
87
|
+
/// to and from <see cref="DateTime"/>, and the <c>yyyy-MM-dd</c> form a contract's <c>date</c>
|
|
88
|
+
/// travels as. Not a complete reimplementation of the framework type.
|
|
89
|
+
/// </remarks>
|
|
90
|
+
public readonly struct DateOnly : IEquatable<DateOnly>, IComparable<DateOnly>, IComparable
|
|
91
|
+
{
|
|
92
|
+
private readonly DateTime _value;
|
|
93
|
+
|
|
94
|
+
public DateOnly(int year, int month, int day)
|
|
95
|
+
{
|
|
96
|
+
_value = new DateTime(year, month, day);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private DateOnly(DateTime value)
|
|
100
|
+
{
|
|
101
|
+
_value = value.Date;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public static DateOnly MinValue => new DateOnly(DateTime.MinValue);
|
|
105
|
+
|
|
106
|
+
public static DateOnly MaxValue => new DateOnly(DateTime.MaxValue);
|
|
107
|
+
|
|
108
|
+
public int Year => _value.Year;
|
|
109
|
+
|
|
110
|
+
public int Month => _value.Month;
|
|
111
|
+
|
|
112
|
+
public int Day => _value.Day;
|
|
113
|
+
|
|
114
|
+
public int DayOfYear => _value.DayOfYear;
|
|
115
|
+
|
|
116
|
+
public DayOfWeek DayOfWeek => _value.DayOfWeek;
|
|
117
|
+
|
|
118
|
+
/// <summary>The date part of <paramref name="value"/>, dropping its time and kind.</summary>
|
|
119
|
+
public static DateOnly FromDateTime(DateTime value) => new DateOnly(value);
|
|
120
|
+
|
|
121
|
+
/// <summary>This date at midnight, with an unspecified kind. What a XAML date picker binds to.</summary>
|
|
122
|
+
public DateTime ToDateTime() => _value;
|
|
123
|
+
|
|
124
|
+
/// <summary>This date at <paramref name="time"/>, with an unspecified kind.</summary>
|
|
125
|
+
public DateTime ToDateTime(TimeOnly time) => _value.Add(time.ToTimeSpan());
|
|
126
|
+
|
|
127
|
+
public DateOnly AddDays(int value) => new DateOnly(_value.AddDays(value));
|
|
128
|
+
|
|
129
|
+
public DateOnly AddMonths(int value) => new DateOnly(_value.AddMonths(value));
|
|
130
|
+
|
|
131
|
+
public DateOnly AddYears(int value) => new DateOnly(_value.AddYears(value));
|
|
132
|
+
|
|
133
|
+
public static DateOnly Parse(string s) => Parse(s, CultureInfo.InvariantCulture);
|
|
134
|
+
|
|
135
|
+
/// <exception cref="FormatException">When <paramref name="s"/> is not a date.</exception>
|
|
136
|
+
public static DateOnly Parse(string s, IFormatProvider? provider)
|
|
137
|
+
{
|
|
138
|
+
if (!TryParse(s, provider, out var result))
|
|
139
|
+
{
|
|
140
|
+
throw new FormatException("'" + s + "' is not a date.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public static bool TryParse(string? s, out DateOnly result) => TryParse(s, CultureInfo.InvariantCulture, out result);
|
|
147
|
+
|
|
148
|
+
/// <remarks>
|
|
149
|
+
/// The wire form is tried first and exactly; anything else the culture reads as a date is
|
|
150
|
+
/// then taken by its date part, which is what the framework type does too.
|
|
151
|
+
/// </remarks>
|
|
152
|
+
public static bool TryParse(string? s, IFormatProvider? provider, out DateOnly result)
|
|
153
|
+
{
|
|
154
|
+
var culture = provider ?? CultureInfo.InvariantCulture;
|
|
155
|
+
if (DateTime.TryParseExact(s, "yyyy-MM-dd", culture, DateTimeStyles.None, out var exact))
|
|
156
|
+
{
|
|
157
|
+
result = new DateOnly(exact);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (DateTime.TryParse(s, culture, DateTimeStyles.None, out var parsed))
|
|
162
|
+
{
|
|
163
|
+
result = new DateOnly(parsed);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
result = default;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// <summary>The ISO 8601 form, <c>yyyy-MM-dd</c>. Also how the date travels on the wire.</summary>
|
|
172
|
+
public override string ToString() => _value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
|
173
|
+
|
|
174
|
+
public string ToString(string? format) => _value.ToString(format, CultureInfo.InvariantCulture);
|
|
175
|
+
|
|
176
|
+
public string ToString(string? format, IFormatProvider? provider) => _value.ToString(format, provider);
|
|
177
|
+
|
|
178
|
+
public bool Equals(DateOnly other) => _value == other._value;
|
|
179
|
+
|
|
180
|
+
public override bool Equals(object? obj) => obj is DateOnly other && Equals(other);
|
|
181
|
+
|
|
182
|
+
public override int GetHashCode() => _value.GetHashCode();
|
|
183
|
+
|
|
184
|
+
public int CompareTo(DateOnly other) => _value.CompareTo(other._value);
|
|
185
|
+
|
|
186
|
+
public int CompareTo(object? obj)
|
|
187
|
+
{
|
|
188
|
+
if (obj is null) return 1;
|
|
189
|
+
if (obj is DateOnly other) return CompareTo(other);
|
|
190
|
+
throw new ArgumentException("Object must be of type DateOnly.", nameof(obj));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
public static bool operator ==(DateOnly left, DateOnly right) => left.Equals(right);
|
|
194
|
+
|
|
195
|
+
public static bool operator !=(DateOnly left, DateOnly right) => !left.Equals(right);
|
|
196
|
+
|
|
197
|
+
public static bool operator <(DateOnly left, DateOnly right) => left.CompareTo(right) < 0;
|
|
198
|
+
|
|
199
|
+
public static bool operator <=(DateOnly left, DateOnly right) => left.CompareTo(right) <= 0;
|
|
200
|
+
|
|
201
|
+
public static bool operator >(DateOnly left, DateOnly right) => left.CompareTo(right) > 0;
|
|
202
|
+
|
|
203
|
+
public static bool operator >=(DateOnly left, DateOnly right) => left.CompareTo(right) >= 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// <summary>
|
|
207
|
+
/// A time of day with no date and no offset. Stands in for <c>System.TimeOnly</c>.
|
|
208
|
+
/// </summary>
|
|
209
|
+
/// <remarks>
|
|
210
|
+
/// At least zero and less than 24 hours, which is what separates it from the
|
|
211
|
+
/// <see cref="TimeSpan"/> a contract's <c>duration</c> maps to.
|
|
212
|
+
/// </remarks>
|
|
213
|
+
public readonly struct TimeOnly : IEquatable<TimeOnly>, IComparable<TimeOnly>, IComparable
|
|
214
|
+
{
|
|
215
|
+
private static readonly TimeSpan OneDay = TimeSpan.FromDays(1);
|
|
216
|
+
|
|
217
|
+
/// <summary>Accepted on the way in: the wire form, with or without a fraction, and <c>HH:mm</c>.</summary>
|
|
218
|
+
private static readonly string[] Formats = { "HH:mm:ss.FFFFFFF", "HH:mm" };
|
|
219
|
+
|
|
220
|
+
private readonly TimeSpan _value;
|
|
221
|
+
|
|
222
|
+
public TimeOnly(int hour, int minute)
|
|
223
|
+
: this(new TimeSpan(hour, minute, 0))
|
|
224
|
+
{
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
public TimeOnly(int hour, int minute, int second)
|
|
228
|
+
: this(new TimeSpan(hour, minute, second))
|
|
229
|
+
{
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
public TimeOnly(int hour, int minute, int second, int millisecond)
|
|
233
|
+
: this(new TimeSpan(0, hour, minute, second, millisecond))
|
|
234
|
+
{
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private TimeOnly(TimeSpan value)
|
|
238
|
+
{
|
|
239
|
+
if (value < TimeSpan.Zero || value >= OneDay)
|
|
240
|
+
{
|
|
241
|
+
throw new ArgumentOutOfRangeException(nameof(value), "A time of day is at least zero and less than 24 hours.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_value = value;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
public static TimeOnly MinValue => new TimeOnly(TimeSpan.Zero);
|
|
248
|
+
|
|
249
|
+
public static TimeOnly MaxValue => new TimeOnly(OneDay - TimeSpan.FromTicks(1));
|
|
250
|
+
|
|
251
|
+
public int Hour => _value.Hours;
|
|
252
|
+
|
|
253
|
+
public int Minute => _value.Minutes;
|
|
254
|
+
|
|
255
|
+
public int Second => _value.Seconds;
|
|
256
|
+
|
|
257
|
+
public int Millisecond => _value.Milliseconds;
|
|
258
|
+
|
|
259
|
+
public long Ticks => _value.Ticks;
|
|
260
|
+
|
|
261
|
+
/// <summary>The time part of <paramref name="value"/>.</summary>
|
|
262
|
+
public static TimeOnly FromDateTime(DateTime value) => new TimeOnly(value.TimeOfDay);
|
|
263
|
+
|
|
264
|
+
/// <exception cref="ArgumentOutOfRangeException">When <paramref name="value"/> is negative or a day or more.</exception>
|
|
265
|
+
public static TimeOnly FromTimeSpan(TimeSpan value) => new TimeOnly(value);
|
|
266
|
+
|
|
267
|
+
/// <summary>This time as a span since midnight. What a XAML time picker binds to.</summary>
|
|
268
|
+
public TimeSpan ToTimeSpan() => _value;
|
|
269
|
+
|
|
270
|
+
public static TimeOnly Parse(string s) => Parse(s, CultureInfo.InvariantCulture);
|
|
271
|
+
|
|
272
|
+
/// <exception cref="FormatException">When <paramref name="s"/> is not a time of day.</exception>
|
|
273
|
+
public static TimeOnly Parse(string s, IFormatProvider? provider)
|
|
274
|
+
{
|
|
275
|
+
if (!TryParse(s, provider, out var result))
|
|
276
|
+
{
|
|
277
|
+
throw new FormatException("'" + s + "' is not a time of day.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
public static bool TryParse(string? s, out TimeOnly result) => TryParse(s, CultureInfo.InvariantCulture, out result);
|
|
284
|
+
|
|
285
|
+
public static bool TryParse(string? s, IFormatProvider? provider, out TimeOnly result)
|
|
286
|
+
{
|
|
287
|
+
var culture = provider ?? CultureInfo.InvariantCulture;
|
|
288
|
+
if (DateTime.TryParseExact(s, Formats, culture, DateTimeStyles.None, out var exact))
|
|
289
|
+
{
|
|
290
|
+
result = new TimeOnly(exact.TimeOfDay);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (DateTime.TryParse(s, culture, DateTimeStyles.None, out var parsed))
|
|
295
|
+
{
|
|
296
|
+
result = new TimeOnly(parsed.TimeOfDay);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
result = default;
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/// <summary>
|
|
305
|
+
/// The ISO 8601 form: <c>HH:mm:ss</c>, and a fraction of seven digits when there is one.
|
|
306
|
+
/// </summary>
|
|
307
|
+
/// <remarks>
|
|
308
|
+
/// Byte for byte what System.Text.Json's own converter writes for a <c>TimeOnly</c> on
|
|
309
|
+
/// net10.0 — including the seven digits, which it does not trim — so a service cannot tell
|
|
310
|
+
/// which leg of the build sent a body.
|
|
311
|
+
/// </remarks>
|
|
312
|
+
public override string ToString() =>
|
|
313
|
+
_value.Ticks % TimeSpan.TicksPerSecond == 0
|
|
314
|
+
? _value.ToString("hh':'mm':'ss", CultureInfo.InvariantCulture)
|
|
315
|
+
: _value.ToString("hh':'mm':'ss'.'fffffff", CultureInfo.InvariantCulture);
|
|
316
|
+
|
|
317
|
+
public string ToString(string? format) => _value.ToString(format, CultureInfo.InvariantCulture);
|
|
318
|
+
|
|
319
|
+
public string ToString(string? format, IFormatProvider? provider) => _value.ToString(format, provider);
|
|
320
|
+
|
|
321
|
+
public bool Equals(TimeOnly other) => _value == other._value;
|
|
322
|
+
|
|
323
|
+
public override bool Equals(object? obj) => obj is TimeOnly other && Equals(other);
|
|
324
|
+
|
|
325
|
+
public override int GetHashCode() => _value.GetHashCode();
|
|
326
|
+
|
|
327
|
+
public int CompareTo(TimeOnly other) => _value.CompareTo(other._value);
|
|
328
|
+
|
|
329
|
+
public int CompareTo(object? obj)
|
|
330
|
+
{
|
|
331
|
+
if (obj is null) return 1;
|
|
332
|
+
if (obj is TimeOnly other) return CompareTo(other);
|
|
333
|
+
throw new ArgumentException("Object must be of type TimeOnly.", nameof(obj));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
public static bool operator ==(TimeOnly left, TimeOnly right) => left.Equals(right);
|
|
337
|
+
|
|
338
|
+
public static bool operator !=(TimeOnly left, TimeOnly right) => !left.Equals(right);
|
|
339
|
+
|
|
340
|
+
public static bool operator <(TimeOnly left, TimeOnly right) => left.CompareTo(right) < 0;
|
|
341
|
+
|
|
342
|
+
public static bool operator <=(TimeOnly left, TimeOnly right) => left.CompareTo(right) <= 0;
|
|
343
|
+
|
|
344
|
+
public static bool operator >(TimeOnly left, TimeOnly right) => left.CompareTo(right) > 0;
|
|
345
|
+
|
|
346
|
+
public static bool operator >=(TimeOnly left, TimeOnly right) => left.CompareTo(right) >= 0;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#endif
|
|
351
|
+
`;
|
|
352
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -62,7 +62,13 @@ public sealed class SdkOptions
|
|
|
62
62
|
public class SdkException : HttpRequestException
|
|
63
63
|
{
|
|
64
64
|
public SdkException(int status, string body, HttpResponseHeaders? responseHeaders = null, string? message = null)
|
|
65
|
+
#if NETSTANDARD2_0
|
|
66
|
+
// .NET Standard 2.0's HttpRequestException carries no status of its own. Status below does,
|
|
67
|
+
// so nothing is lost but the base type's own StatusCode property.
|
|
68
|
+
: base(message ?? $"Request failed with status {status}")
|
|
69
|
+
#else
|
|
65
70
|
: base(message ?? $"Request failed with status {status}", null, ToStatusCode(status))
|
|
71
|
+
#endif
|
|
66
72
|
{
|
|
67
73
|
Status = status;
|
|
68
74
|
Body = body;
|
|
@@ -121,8 +127,10 @@ public class SdkException : HttpRequestException
|
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
#if !NETSTANDARD2_0
|
|
124
131
|
private static HttpStatusCode? ToStatusCode(int status) =>
|
|
125
132
|
status is >= 100 and <= 599 ? (HttpStatusCode)status : null;
|
|
133
|
+
#endif
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
/// <summary>
|
|
@@ -210,6 +218,16 @@ public sealed class SdkHttp : IDisposable
|
|
|
210
218
|
|
|
211
219
|
public JsonSerializerOptions Json { get; }
|
|
212
220
|
|
|
221
|
+
/// <summary>
|
|
222
|
+
/// The PATCH verb.
|
|
223
|
+
/// </summary>
|
|
224
|
+
/// <remarks>
|
|
225
|
+
/// <c>HttpMethod</c> carries a static for every other verb a contract can declare, but not for
|
|
226
|
+
/// this one on every framework the SDK builds against, so it is spelled once here rather than
|
|
227
|
+
/// allocated per call.
|
|
228
|
+
/// </remarks>
|
|
229
|
+
public static readonly HttpMethod Patch = new HttpMethod("PATCH");
|
|
230
|
+
|
|
213
231
|
/// <summary>
|
|
214
232
|
/// Send one request and read its body.
|
|
215
233
|
/// </summary>
|
|
@@ -248,7 +266,13 @@ public sealed class SdkHttp : IDisposable
|
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
var message = await Client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
|
269
|
+
#if NETSTANDARD2_0
|
|
270
|
+
// The cancellable overload arrived in .NET 5. ResponseContentRead above has already buffered
|
|
271
|
+
// the body, so this read is a copy rather than a wait on the network.
|
|
272
|
+
var bytes = await message.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
|
273
|
+
#else
|
|
251
274
|
var bytes = await message.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
|
275
|
+
#endif
|
|
252
276
|
var response = new SdkResponse(message, bytes);
|
|
253
277
|
|
|
254
278
|
var status = response.Status;
|
package/src/scaffold.ts
CHANGED
|
@@ -3,39 +3,78 @@
|
|
|
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
|
*/
|
|
8
10
|
|
|
11
|
+
/** The frameworks a generated SDK can be built for. */
|
|
12
|
+
export type CSharpTargetFramework = 'netstandard2.0' | 'net10.0';
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* What the scaffold pins. One object so a bump is one edit.
|
|
11
16
|
*
|
|
12
|
-
*
|
|
17
|
+
* A single-framework SDK on `net10.0` has no dependency list to go with it: it uses only
|
|
13
18
|
* `System.Text.Json` and `HttpClient` from the shared framework, so `dotnet build` restores with no
|
|
14
|
-
* NuGet feed reachable at all.
|
|
19
|
+
* NuGet feed reachable at all. `netstandard2.0` is the exception — `System.Text.Json` is a package
|
|
20
|
+
* there, and one that brings `System.Memory` and `System.Threading.Tasks.Extensions` with it.
|
|
15
21
|
*/
|
|
16
22
|
export const SCAFFOLD_VERSIONS = {
|
|
17
23
|
targetFramework: 'net10.0',
|
|
24
|
+
systemTextJson: '10.0.12',
|
|
25
|
+
/** The oldest language version the generated sources compile under: records, `required`, primary constructors. */
|
|
26
|
+
netstandardLangVersion: '12.0',
|
|
18
27
|
} as const;
|
|
19
28
|
|
|
29
|
+
/** The framework the scaffold targets when the config names none. */
|
|
30
|
+
export const DEFAULT_TARGET_FRAMEWORKS: readonly CSharpTargetFramework[] = [SCAFFOLD_VERSIONS.targetFramework];
|
|
31
|
+
|
|
20
32
|
/**
|
|
21
33
|
* Generate `<SdkName>.csproj`.
|
|
22
34
|
*
|
|
23
35
|
* `ImplicitUsings` is off because generated files carry an explicit `using` block of their own, and
|
|
24
36
|
* leaving it on would make the output depend on the SDK's implicit set rather than on what the
|
|
25
37
|
* generator wrote.
|
|
38
|
+
*
|
|
39
|
+
* Only a build that includes `netstandard2.0` pins `LangVersion` or references a package. On its own,
|
|
40
|
+
* `net10.0` defaults to the newest language version the SDK knows, and pinning one here would hold a
|
|
41
|
+
* project back rather than help it.
|
|
26
42
|
*/
|
|
27
|
-
export function generateCsproj(
|
|
43
|
+
export function generateCsproj(
|
|
44
|
+
namespaceName: string,
|
|
45
|
+
sdkName: string,
|
|
46
|
+
targetFrameworks: readonly CSharpTargetFramework[] = DEFAULT_TARGET_FRAMEWORKS,
|
|
47
|
+
): string {
|
|
48
|
+
const frameworks =
|
|
49
|
+
targetFrameworks.length === 1
|
|
50
|
+
? ` <TargetFramework>${targetFrameworks[0]}</TargetFramework>`
|
|
51
|
+
: ` <TargetFrameworks>${targetFrameworks.join(';')}</TargetFrameworks>`;
|
|
52
|
+
|
|
53
|
+
const netstandard = targetFrameworks.includes('netstandard2.0')
|
|
54
|
+
? `
|
|
55
|
+
<!-- .NET Standard 2.0 predates the language features the generated sources use. -->
|
|
56
|
+
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
|
57
|
+
<LangVersion>${SCAFFOLD_VERSIONS.netstandardLangVersion}</LangVersion>
|
|
58
|
+
</PropertyGroup>
|
|
59
|
+
|
|
60
|
+
<!-- The one framework where System.Text.Json is a package rather than part of the platform. -->
|
|
61
|
+
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
|
62
|
+
<PackageReference Include="System.Text.Json" Version="${SCAFFOLD_VERSIONS.systemTextJson}" />
|
|
63
|
+
</ItemGroup>
|
|
64
|
+
`
|
|
65
|
+
: '';
|
|
66
|
+
|
|
28
67
|
return `<!-- Created once by @contractkit/plugin-csharp. Yours to edit: it is never regenerated. -->
|
|
29
68
|
<Project Sdk="Microsoft.NET.Sdk">
|
|
30
69
|
|
|
31
70
|
<PropertyGroup>
|
|
32
|
-
|
|
71
|
+
${frameworks}
|
|
33
72
|
<Nullable>enable</Nullable>
|
|
34
73
|
<ImplicitUsings>disable</ImplicitUsings>
|
|
35
74
|
<RootNamespace>${namespaceName}</RootNamespace>
|
|
36
75
|
<AssemblyName>${sdkName}</AssemblyName>
|
|
37
76
|
</PropertyGroup>
|
|
38
|
-
|
|
77
|
+
${netstandard}
|
|
39
78
|
</Project>
|
|
40
79
|
`;
|
|
41
80
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { buildModelIndex } from '@contractkit/core';
|
|
3
3
|
import type { ContractRootNode, OpRootNode } from '@contractkit/core';
|
|
4
4
|
import { buildPathExpression, deriveClientClassName, deriveMethodName, generateCSharpClient, hasPublicOperations } from '../src/codegen-client.js';
|
|
5
|
+
import type { CSharpDateTypes } from '../src/codegen-models.js';
|
|
5
6
|
import { collectHoistedTypes } from '../src/hoist.js';
|
|
6
7
|
import {
|
|
7
8
|
contractRoot,
|
|
@@ -18,12 +19,22 @@ import {
|
|
|
18
19
|
scalarType,
|
|
19
20
|
} from './helpers.js';
|
|
20
21
|
|
|
21
|
-
function render(
|
|
22
|
+
function render(
|
|
23
|
+
root: OpRootNode,
|
|
24
|
+
opts: { contracts?: ContractRootNode[]; modelsWithInput?: Set<string>; includeInternal?: boolean; dateTypes?: CSharpDateTypes } = {},
|
|
25
|
+
): string {
|
|
22
26
|
const contracts = opts.contracts ?? [];
|
|
23
27
|
const modelIndex = buildModelIndex(contracts.flatMap(r => r.models));
|
|
24
28
|
const modelsWithInput = opts.modelsWithInput ?? new Set<string>();
|
|
25
29
|
const hoisted = collectHoistedTypes(contracts, { modelIndex, modelsWithInput });
|
|
26
|
-
return generateCSharpClient(root, {
|
|
30
|
+
return generateCSharpClient(root, {
|
|
31
|
+
namespace: 'Acme.Sdk',
|
|
32
|
+
dateTypes: opts.dateTypes,
|
|
33
|
+
modelsWithInput,
|
|
34
|
+
modelIndex,
|
|
35
|
+
hoisted,
|
|
36
|
+
includeInternal: opts.includeInternal,
|
|
37
|
+
});
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
describe('naming', () => {
|
|
@@ -85,6 +96,21 @@ describe('class and method shape', () => {
|
|
|
85
96
|
expect(out).not.toContain('var response = await');
|
|
86
97
|
});
|
|
87
98
|
|
|
99
|
+
it('names the verb through HttpMethod, and PATCH through the runtime static instead', () => {
|
|
100
|
+
const verbs: [Parameters<typeof opOperation>[0], string][] = [
|
|
101
|
+
['get', 'HttpMethod.Get'],
|
|
102
|
+
['post', 'HttpMethod.Post'],
|
|
103
|
+
['put', 'HttpMethod.Put'],
|
|
104
|
+
['delete', 'HttpMethod.Delete'],
|
|
105
|
+
// `HttpMethod.Patch` does not exist on netstandard2.0, so the runtime spells this one.
|
|
106
|
+
['patch', 'SdkHttp.Patch'],
|
|
107
|
+
];
|
|
108
|
+
for (const [method, expression] of verbs) {
|
|
109
|
+
const root = opRoot([opRoute('/payments', [opOperation(method, { sdk: 'act' })])]);
|
|
110
|
+
expect(render(root, { contracts })).toContain(` ${expression},`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
88
114
|
it('sends the Input variant of a body model', () => {
|
|
89
115
|
const root = opRoot([opRoute('/payments', [opOperation('post', { sdk: 'create', request: opRequest('Payment') })])]);
|
|
90
116
|
const out = render(root, { contracts, modelsWithInput: new Set(['Payment']) });
|
|
@@ -271,9 +297,9 @@ describe('responses', () => {
|
|
|
271
297
|
describe('response headers', () => {
|
|
272
298
|
const contracts = [contractRoot([model('Payment', [field('id', scalarType('uuid'))])])];
|
|
273
299
|
|
|
274
|
-
function withHeaders(headers: { name: string; optional: boolean; type: ReturnType<typeof scalarType> }[]): string {
|
|
300
|
+
function withHeaders(headers: { name: string; optional: boolean; type: ReturnType<typeof scalarType> }[], dateTypes?: CSharpDateTypes): string {
|
|
275
301
|
const root = opRoot([opRoute('/payments', [opOperation('get', { sdk: 'get', responses: [{ ...opResponse(200, 'Payment'), headers }] })])]);
|
|
276
|
-
return render(root, { contracts });
|
|
302
|
+
return render(root, { contracts, dateTypes });
|
|
277
303
|
}
|
|
278
304
|
|
|
279
305
|
it('requires a declared header and parses it to its type', () => {
|
|
@@ -298,6 +324,14 @@ describe('response headers', () => {
|
|
|
298
324
|
expect(withHeaders([{ name: 'h', optional: false, type: scalarType('datetime') }])).toContain('DateTimeOffset.Parse(');
|
|
299
325
|
expect(withHeaders([{ name: 'h', optional: false, type: scalarType('duration') }])).toContain('XmlConvert.ToTimeSpan(');
|
|
300
326
|
expect(withHeaders([{ name: 'h', optional: false, type: scalarType('bigint') }])).toContain('BigInteger.Parse(');
|
|
327
|
+
expect(withHeaders([{ name: 'h', optional: false, type: scalarType('date') }])).toContain('DateOnly.Parse(');
|
|
328
|
+
expect(withHeaders([{ name: 'h', optional: false, type: scalarType('time') }])).toContain('TimeOnly.Parse(');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('reads a date header as the type dateTypes asked for, so a header matches its model field', () => {
|
|
332
|
+
const out = withHeaders([{ name: 'x-day', optional: false, type: scalarType('date') }], 'datetime');
|
|
333
|
+
expect(out).toContain('DateTime.Parse(http.RequireHeader(response, "x-day"), CultureInfo.InvariantCulture)');
|
|
334
|
+
expect(out).toContain('public sealed record GetHeaders(DateTime XDay);');
|
|
301
335
|
});
|
|
302
336
|
|
|
303
337
|
it('renames an optional header pattern variable that would redeclare a parameter', () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { ContractRootNode, ScalarTypeNode } from '@contractkit/core';
|
|
3
3
|
import { buildModelIndex } from '@contractkit/core';
|
|
4
|
-
import { generateCSharpModels } from '../src/codegen-models.js';
|
|
4
|
+
import { generateCSharpModels, type CSharpDateTypes } from '../src/codegen-models.js';
|
|
5
5
|
import { collectHoistedTypes } from '../src/hoist.js';
|
|
6
6
|
import {
|
|
7
7
|
arrayType,
|
|
@@ -21,13 +21,18 @@ import {
|
|
|
21
21
|
/** Render a root the way the plugin does: hoist across the project, then generate. */
|
|
22
22
|
function render(
|
|
23
23
|
root: ContractRootNode,
|
|
24
|
-
opts: {
|
|
24
|
+
opts: {
|
|
25
|
+
modelsWithInput?: Set<string>;
|
|
26
|
+
warn?: (m: string) => void;
|
|
27
|
+
roots?: ContractRootNode[];
|
|
28
|
+
dateTypes?: CSharpDateTypes;
|
|
29
|
+
} = {},
|
|
25
30
|
): string {
|
|
26
31
|
const roots = opts.roots ?? [root];
|
|
27
32
|
const modelIndex = buildModelIndex(roots.flatMap(r => r.models));
|
|
28
33
|
const modelsWithInput = opts.modelsWithInput ?? new Set<string>();
|
|
29
34
|
const hoisted = collectHoistedTypes(roots, { modelIndex, modelsWithInput, warn: message => opts.warn?.(message) });
|
|
30
|
-
return generateCSharpModels(root, { namespace: 'Acme.Sdk', modelsWithInput, modelIndex, hoisted, warn: opts.warn });
|
|
35
|
+
return generateCSharpModels(root, { namespace: 'Acme.Sdk', dateTypes: opts.dateTypes, modelsWithInput, modelIndex, hoisted, warn: opts.warn });
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function one(name: string, ...fields: Parameters<typeof field>[] extends never ? never : ReturnType<typeof field>[]): ContractRootNode {
|
|
@@ -79,6 +84,20 @@ describe('scalar mapping', () => {
|
|
|
79
84
|
it('maps a null scalar to a nullable object', () => {
|
|
80
85
|
expect(render(one('M', field('f', scalarType('null'))))).toContain('public required object? F { get; init; }');
|
|
81
86
|
});
|
|
87
|
+
|
|
88
|
+
it('maps date to DateTime under dateTypes, and leaves time alone', () => {
|
|
89
|
+
const root = contractRoot([model('M', [field('d', scalarType('date')), field('t', scalarType('time'))])]);
|
|
90
|
+
const out = render(root, { dateTypes: 'datetime' });
|
|
91
|
+
expect(out).toContain('public required DateTime D { get; init; }');
|
|
92
|
+
// `duration` is already TimeSpan and serialization dispatches on the CLR type, so a time
|
|
93
|
+
// carried as one would go out as PT9H30M.
|
|
94
|
+
expect(out).toContain('public required TimeOnly T { get; init; }');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('treats a DateTime date as a value type, so an optional one is Nullable rather than a reference', () => {
|
|
98
|
+
const root = contractRoot([model('M', [field('d', scalarType('date'), { optional: true })])]);
|
|
99
|
+
expect(render(root, { dateTypes: 'datetime' })).toContain('public DateTime? D { get; init; }');
|
|
100
|
+
});
|
|
82
101
|
});
|
|
83
102
|
|
|
84
103
|
describe('composite types', () => {
|
|
@@ -255,6 +274,24 @@ describe('declarations', () => {
|
|
|
255
274
|
expect(render(root)).toContain('global using Ps = System.Collections.Generic.List<Acme.Sdk.Models.P>;');
|
|
256
275
|
});
|
|
257
276
|
|
|
277
|
+
it('names both spellings of a polyfilled alias target, the one thing a file import cannot cover', () => {
|
|
278
|
+
const root = contractRoot([model('Day', [], { type: scalarType('date') }), model('Days', [], { type: arrayType(scalarType('time')) })]);
|
|
279
|
+
const out = render(root);
|
|
280
|
+
expect(out).toContain(
|
|
281
|
+
'#if NETSTANDARD2_0\nglobal using Day = Acme.Sdk.Runtime.DateOnly;\n#else\nglobal using Day = System.DateOnly;\n#endif',
|
|
282
|
+
);
|
|
283
|
+
// A container around the polyfilled type is rewritten in place rather than missed.
|
|
284
|
+
expect(out).toContain('global using Days = System.Collections.Generic.List<Acme.Sdk.Runtime.TimeOnly>;');
|
|
285
|
+
expect(out).toContain('global using Days = System.Collections.Generic.List<System.TimeOnly>;');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('imports the runtime namespace, which is where DateOnly comes from on an older framework', () => {
|
|
289
|
+
const root = contractRoot([model('Slot', [field('day', scalarType('date'))])]);
|
|
290
|
+
const out = render(root);
|
|
291
|
+
expect(out).toContain('using Acme.Sdk.Runtime;');
|
|
292
|
+
expect(out).toContain('public required DateOnly Day { get; init; }');
|
|
293
|
+
});
|
|
294
|
+
|
|
258
295
|
it('drops nullability from an alias, which C# cannot express, and says so', () => {
|
|
259
296
|
const warnings: string[] = [];
|
|
260
297
|
const root = contractRoot([model('MaybeName', [], { type: unionType(scalarType('string'), scalarType('null')) })]);
|