@contractkit/plugin-csharp 0.0.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 +13 -0
- package/.turbo/turbo-build.log +12 -0
- package/.turbo/turbo-format.log +34 -0
- package/.turbo/turbo-test.log +17 -0
- package/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/codegen-client.d.ts +35 -0
- package/dist/codegen-client.d.ts.map +1 -0
- package/dist/codegen-models.d.ts +75 -0
- package/dist/codegen-models.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +13 -0
- package/dist/codegen-sdk.d.ts.map +1 -0
- package/dist/hoist.d.ts +53 -0
- package/dist/hoist.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2569 -0
- package/dist/index.js.map +1 -0
- package/dist/naming.d.ts +89 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/runtime-converters.d.ts +15 -0
- package/dist/runtime-converters.d.ts.map +1 -0
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/scaffold.d.ts +26 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +48 -0
- package/src/codegen-client.ts +680 -0
- package/src/codegen-models.ts +909 -0
- package/src/codegen-sdk.ts +52 -0
- package/src/hoist.ts +402 -0
- package/src/index.ts +373 -0
- package/src/naming.ts +262 -0
- package/src/runtime-converters.ts +147 -0
- package/src/runtime.ts +381 -0
- package/src/scaffold.ts +41 -0
- package/tests/codegen-client.test.ts +275 -0
- package/tests/codegen-models.test.ts +410 -0
- package/tests/helpers.ts +202 -0
- package/tests/hoist.test.ts +92 -0
- package/tests/index.test.ts +124 -0
- package/tests/naming.test.ts +133 -0
- package/tests/runtime.test.ts +104 -0
- package/tests/scaffold.test.ts +28 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Runtime/Converters.cs` file: the shared `JsonSerializerOptions` and the converters for the
|
|
3
|
+
* three ContractKit scalars whose BCL type does not serialize the way the contract says it travels.
|
|
4
|
+
*
|
|
5
|
+
* These live in the generated output rather than in a published NuGet package so the SDK has no
|
|
6
|
+
* dependency at all. The same choice the Python plugin makes with `_base_client.py` and the Kotlin
|
|
7
|
+
* plugin with `Serializers.kt`.
|
|
8
|
+
*
|
|
9
|
+
* Enums, unions and tuples are not handled here: each carries a `[JsonConverter]` attribute of its
|
|
10
|
+
* own, so it serializes correctly under any options. These three are options-level, which is why
|
|
11
|
+
* anything serializing a generated model by hand has to pass `SdkJson.Options`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Generate `Runtime/Converters.cs` for `namespaceName`. Content depends on nothing but the namespace. */
|
|
15
|
+
export function generateConvertersCs(namespaceName: string): string {
|
|
16
|
+
return `// <auto-generated/>
|
|
17
|
+
// Generated by @contractkit/plugin-csharp. Do not edit manually.
|
|
18
|
+
#nullable enable
|
|
19
|
+
|
|
20
|
+
using System;
|
|
21
|
+
using System.Buffers;
|
|
22
|
+
using System.Globalization;
|
|
23
|
+
using System.Numerics;
|
|
24
|
+
using System.Text;
|
|
25
|
+
using System.Text.Json;
|
|
26
|
+
using System.Text.Json.Serialization;
|
|
27
|
+
using System.Xml;
|
|
28
|
+
|
|
29
|
+
namespace ${namespaceName}.Runtime;
|
|
30
|
+
|
|
31
|
+
/// <summary>
|
|
32
|
+
/// How the SDK reads and writes JSON.
|
|
33
|
+
/// </summary>
|
|
34
|
+
/// <remarks>
|
|
35
|
+
/// Unknown properties are skipped, which is the default, so a service adding a field does not break
|
|
36
|
+
/// an older client. Serialize a generated model with these options: three of the contract's scalar
|
|
37
|
+
/// types need the converters registered here.
|
|
38
|
+
/// </remarks>
|
|
39
|
+
public static class SdkJson
|
|
40
|
+
{
|
|
41
|
+
public static readonly JsonSerializerOptions Options = CreateOptions();
|
|
42
|
+
|
|
43
|
+
private static JsonSerializerOptions CreateOptions()
|
|
44
|
+
{
|
|
45
|
+
var options = new JsonSerializerOptions();
|
|
46
|
+
options.Converters.Add(new BigIntegerConverter());
|
|
47
|
+
options.Converters.Add(new DecimalStringConverter());
|
|
48
|
+
options.Converters.Add(new IsoTimeSpanConverter());
|
|
49
|
+
return options;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// <summary>
|
|
54
|
+
/// An arbitrary-precision integer. Written as a plain digit string.
|
|
55
|
+
/// </summary>
|
|
56
|
+
/// <remarks>
|
|
57
|
+
/// Reading accepts a digit string, the <c>123n</c> form the TypeScript SDK writes, and a JSON
|
|
58
|
+
/// number, so a body written by any ContractKit client reads back here.
|
|
59
|
+
/// </remarks>
|
|
60
|
+
public sealed class BigIntegerConverter : JsonConverter<BigInteger>
|
|
61
|
+
{
|
|
62
|
+
public override BigInteger Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
63
|
+
{
|
|
64
|
+
if (reader.TokenType == JsonTokenType.String)
|
|
65
|
+
{
|
|
66
|
+
var text = reader.GetString() ?? throw new JsonException("Expected a digit string for a bigint.");
|
|
67
|
+
return BigInteger.Parse(text.TrimEnd('n'), NumberStyles.Integer, CultureInfo.InvariantCulture);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (reader.TokenType == JsonTokenType.Number)
|
|
71
|
+
{
|
|
72
|
+
// 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);
|
|
75
|
+
return BigInteger.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new JsonException($"Expected a JSON string or number for a bigint, got {reader.TokenType}.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializerOptions options)
|
|
82
|
+
{
|
|
83
|
+
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// <summary>
|
|
88
|
+
/// An exact decimal number. Travels as a quoted JSON string, never as a JSON number.
|
|
89
|
+
/// </summary>
|
|
90
|
+
/// <remarks>
|
|
91
|
+
/// A JSON number has already been through a double by the time it reaches this converter, so the
|
|
92
|
+
/// precision the contract asked for is gone. Reading an unquoted number is rejected for that
|
|
93
|
+
/// reason, which matches the Kotlin SDK and the server's own schema.
|
|
94
|
+
/// </remarks>
|
|
95
|
+
public sealed class DecimalStringConverter : JsonConverter<decimal>
|
|
96
|
+
{
|
|
97
|
+
public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
98
|
+
{
|
|
99
|
+
if (reader.TokenType != JsonTokenType.String)
|
|
100
|
+
{
|
|
101
|
+
throw new JsonException($"Expected a quoted decimal string, got {reader.TokenType}.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var text = reader.GetString() ?? throw new JsonException("Expected a decimal string.");
|
|
105
|
+
return decimal.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options)
|
|
109
|
+
{
|
|
110
|
+
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// <summary>
|
|
115
|
+
/// A duration, as an ISO 8601 string such as <c>PT1H30M</c>.
|
|
116
|
+
/// </summary>
|
|
117
|
+
/// <remarks>
|
|
118
|
+
/// System.Text.Json writes a <c>TimeSpan</c> as <c>d.hh:mm:ss</c> by default, which no other
|
|
119
|
+
/// ContractKit SDK would read, so this converter is not optional.
|
|
120
|
+
/// </remarks>
|
|
121
|
+
public sealed class IsoTimeSpanConverter : JsonConverter<TimeSpan>
|
|
122
|
+
{
|
|
123
|
+
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
124
|
+
{
|
|
125
|
+
if (reader.TokenType != JsonTokenType.String)
|
|
126
|
+
{
|
|
127
|
+
throw new JsonException($"Expected an ISO 8601 duration string, got {reader.TokenType}.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
var text = reader.GetString() ?? throw new JsonException("Expected an ISO 8601 duration string.");
|
|
131
|
+
try
|
|
132
|
+
{
|
|
133
|
+
return XmlConvert.ToTimeSpan(text);
|
|
134
|
+
}
|
|
135
|
+
catch (FormatException error)
|
|
136
|
+
{
|
|
137
|
+
throw new JsonException($"'{text}' is not an ISO 8601 duration.", error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
|
|
142
|
+
{
|
|
143
|
+
writer.WriteStringValue(XmlConvert.ToString(value));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
`;
|
|
147
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Runtime/SdkRuntime.cs` file: the `HttpClient` wrapper every generated client is built on.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here comes from outside the BCL, so a generated SDK restores and builds with no NuGet
|
|
5
|
+
* feed at all. The TypeScript, Python and Kotlin SDKs likewise read and write their own bodies
|
|
6
|
+
* rather than delegating to a content-negotiation layer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Generate `Runtime/SdkRuntime.cs` for `namespaceName`. Content depends on nothing but the namespace. */
|
|
10
|
+
export function generateRuntimeCs(namespaceName: string): string {
|
|
11
|
+
return `// <auto-generated/>
|
|
12
|
+
// Generated by @contractkit/plugin-csharp. Do not edit manually.
|
|
13
|
+
#nullable enable
|
|
14
|
+
|
|
15
|
+
using System;
|
|
16
|
+
using System.Collections.Generic;
|
|
17
|
+
using System.Linq;
|
|
18
|
+
using System.Net;
|
|
19
|
+
using System.Net.Http;
|
|
20
|
+
using System.Net.Http.Headers;
|
|
21
|
+
using System.Text;
|
|
22
|
+
using System.Text.Json;
|
|
23
|
+
using System.Threading;
|
|
24
|
+
using System.Threading.Tasks;
|
|
25
|
+
|
|
26
|
+
namespace ${namespaceName}.Runtime;
|
|
27
|
+
|
|
28
|
+
/// <summary>
|
|
29
|
+
/// How to reach the service.
|
|
30
|
+
/// </summary>
|
|
31
|
+
public sealed class SdkOptions
|
|
32
|
+
{
|
|
33
|
+
/// <summary>Origin, optionally with a path prefix. Operation paths are appended to it.</summary>
|
|
34
|
+
public required string BaseUrl { get; init; }
|
|
35
|
+
|
|
36
|
+
/// <summary>
|
|
37
|
+
/// Called once per request. Authentication belongs here: returning a fresh map each time lets a
|
|
38
|
+
/// token be refreshed without rebuilding the SDK.
|
|
39
|
+
/// </summary>
|
|
40
|
+
public Func<CancellationToken, ValueTask<IReadOnlyDictionary<string, string>>>? Headers { get; init; }
|
|
41
|
+
|
|
42
|
+
/// <summary>
|
|
43
|
+
/// Supply your own client to control handlers, proxies or retries. When you do, the SDK never
|
|
44
|
+
/// disposes it. Leave it null and the SDK creates and owns one.
|
|
45
|
+
/// </summary>
|
|
46
|
+
public HttpClient? HttpClient { get; init; }
|
|
47
|
+
|
|
48
|
+
/// <summary>
|
|
49
|
+
/// How bodies, query values and headers are serialized. Defaults to <see cref="SdkJson.Options"/>,
|
|
50
|
+
/// which carries the converters the contract's scalar types need.
|
|
51
|
+
/// </summary>
|
|
52
|
+
public JsonSerializerOptions Json { get; init; } = SdkJson.Options;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// <summary>
|
|
56
|
+
/// A status the contract does not account for.
|
|
57
|
+
/// </summary>
|
|
58
|
+
/// <remarks>
|
|
59
|
+
/// Derives from <see cref="HttpRequestException"/>, so it can be caught alongside any other client
|
|
60
|
+
/// failure, and passes the status through to <c>StatusCode</c> for callers that catch the base type.
|
|
61
|
+
/// </remarks>
|
|
62
|
+
public class SdkException : HttpRequestException
|
|
63
|
+
{
|
|
64
|
+
public SdkException(int status, string body, HttpResponseHeaders? responseHeaders = null, string? message = null)
|
|
65
|
+
: base(message ?? $"Request failed with status {status}", null, ToStatusCode(status))
|
|
66
|
+
{
|
|
67
|
+
Status = status;
|
|
68
|
+
Body = body;
|
|
69
|
+
ResponseHeaders = responseHeaders;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// <summary>The HTTP status the service returned.</summary>
|
|
73
|
+
public int Status { get; }
|
|
74
|
+
|
|
75
|
+
/// <summary>The raw response body, read as UTF-8 text.</summary>
|
|
76
|
+
public string Body { get; }
|
|
77
|
+
|
|
78
|
+
/// <summary>The response headers, when the failure came from a response rather than a missing header.</summary>
|
|
79
|
+
public HttpResponseHeaders? ResponseHeaders { get; }
|
|
80
|
+
|
|
81
|
+
private bool _jsonParsed;
|
|
82
|
+
private JsonElement? _json;
|
|
83
|
+
|
|
84
|
+
/// <summary>
|
|
85
|
+
/// The body parsed as JSON, or null when it is not JSON. Error contracts usually are.
|
|
86
|
+
/// </summary>
|
|
87
|
+
public JsonElement? Json
|
|
88
|
+
{
|
|
89
|
+
get
|
|
90
|
+
{
|
|
91
|
+
if (_jsonParsed) return _json;
|
|
92
|
+
_jsonParsed = true;
|
|
93
|
+
try
|
|
94
|
+
{
|
|
95
|
+
using var document = JsonDocument.Parse(Body);
|
|
96
|
+
// Clone detaches the element from the document being disposed here.
|
|
97
|
+
_json = document.RootElement.Clone();
|
|
98
|
+
}
|
|
99
|
+
catch (JsonException)
|
|
100
|
+
{
|
|
101
|
+
_json = null;
|
|
102
|
+
}
|
|
103
|
+
return _json;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// <summary>
|
|
108
|
+
/// Read the error body as <typeparamref name="T"/>, or null when it does not parse. Operations
|
|
109
|
+
/// whose thrown statuses declare a body generate an alias naming the type to use here.
|
|
110
|
+
/// </summary>
|
|
111
|
+
public T? TryReadBody<T>(JsonSerializerOptions? options = null)
|
|
112
|
+
where T : class
|
|
113
|
+
{
|
|
114
|
+
try
|
|
115
|
+
{
|
|
116
|
+
return JsonSerializer.Deserialize<T>(Body, options ?? SdkJson.Options);
|
|
117
|
+
}
|
|
118
|
+
catch (JsonException)
|
|
119
|
+
{
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private static HttpStatusCode? ToStatusCode(int status) =>
|
|
125
|
+
status is >= 100 and <= 599 ? (HttpStatusCode)status : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// <summary>
|
|
129
|
+
/// One response, with its body already read.
|
|
130
|
+
/// </summary>
|
|
131
|
+
/// <remarks>
|
|
132
|
+
/// Reading the body eagerly is what allows a generated method to check the status, then the content
|
|
133
|
+
/// type, then decode, which is exactly what an operation declaring several statuses or several
|
|
134
|
+
/// mimes has to do.
|
|
135
|
+
/// </remarks>
|
|
136
|
+
public sealed class SdkResponse
|
|
137
|
+
{
|
|
138
|
+
private string? _text;
|
|
139
|
+
|
|
140
|
+
public SdkResponse(HttpResponseMessage message, byte[] bytes)
|
|
141
|
+
{
|
|
142
|
+
Message = message;
|
|
143
|
+
Bytes = bytes;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public HttpResponseMessage Message { get; }
|
|
147
|
+
|
|
148
|
+
/// <summary>The body as raw bytes.</summary>
|
|
149
|
+
public byte[] Bytes { get; }
|
|
150
|
+
|
|
151
|
+
public int Status => (int)Message.StatusCode;
|
|
152
|
+
|
|
153
|
+
/// <summary>The response mime without its parameters, so <c>application/json; charset=utf-8</c> matches.</summary>
|
|
154
|
+
public string ContentType => Message.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
|
155
|
+
|
|
156
|
+
/// <summary>The body decoded as UTF-8.</summary>
|
|
157
|
+
public string Text => _text ??= Encoding.UTF8.GetString(Bytes);
|
|
158
|
+
|
|
159
|
+
/// <summary>
|
|
160
|
+
/// The first value of a response or content header, or null when the service did not send it.
|
|
161
|
+
/// </summary>
|
|
162
|
+
public string? Header(string name)
|
|
163
|
+
{
|
|
164
|
+
if (Message.Headers.TryGetValues(name, out var values)) return values.FirstOrDefault();
|
|
165
|
+
if (Message.Content.Headers.TryGetValues(name, out var contentValues)) return contentValues.FirstOrDefault();
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// <summary>
|
|
171
|
+
/// One part of a multipart request body.
|
|
172
|
+
/// </summary>
|
|
173
|
+
public sealed record SdkPart(string Name, HttpContent Content, string? FileName = null)
|
|
174
|
+
{
|
|
175
|
+
/// <summary>A plain text field.</summary>
|
|
176
|
+
public static SdkPart Text(string name, string value) => new(name, new StringContent(value, Encoding.UTF8));
|
|
177
|
+
|
|
178
|
+
/// <summary>A file field, sent with a filename and its own content type.</summary>
|
|
179
|
+
public static SdkPart File(string name, byte[] bytes, string fileName, string contentType = "application/octet-stream")
|
|
180
|
+
{
|
|
181
|
+
var content = new ByteArrayContent(bytes);
|
|
182
|
+
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
|
183
|
+
return new SdkPart(name, content, fileName);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// <summary>
|
|
188
|
+
/// Issues requests and turns anything the contract does not describe into an <see cref="SdkException"/>.
|
|
189
|
+
/// </summary>
|
|
190
|
+
/// <remarks>
|
|
191
|
+
/// Every value bound for a path, query, header or form field is turned into text by serializing it
|
|
192
|
+
/// the same way it would be serialized into a JSON body. That is what keeps a <c>Guid</c>, a
|
|
193
|
+
/// <c>DateTimeOffset</c>, a <c>TimeSpan</c> or an enum spelled identically wherever it appears in a
|
|
194
|
+
/// request, without a line of per-type code in the generator.
|
|
195
|
+
/// </remarks>
|
|
196
|
+
public sealed class SdkHttp : IDisposable
|
|
197
|
+
{
|
|
198
|
+
private readonly SdkOptions _options;
|
|
199
|
+
private readonly bool _ownsClient;
|
|
200
|
+
|
|
201
|
+
public SdkHttp(SdkOptions options)
|
|
202
|
+
{
|
|
203
|
+
_options = options;
|
|
204
|
+
_ownsClient = options.HttpClient is null;
|
|
205
|
+
Client = options.HttpClient ?? new HttpClient();
|
|
206
|
+
Json = options.Json;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
public HttpClient Client { get; }
|
|
210
|
+
|
|
211
|
+
public JsonSerializerOptions Json { get; }
|
|
212
|
+
|
|
213
|
+
/// <summary>
|
|
214
|
+
/// Send one request and read its body.
|
|
215
|
+
/// </summary>
|
|
216
|
+
/// <remarks>
|
|
217
|
+
/// <c>expectStatuses</c> carries the statuses the operation declares as outcomes rather than
|
|
218
|
+
/// failures, such as a 404 the contract gives a meaning. Everything outside 2xx and that set
|
|
219
|
+
/// throws.
|
|
220
|
+
/// </remarks>
|
|
221
|
+
/// <exception cref="SdkException">On a status the contract does not declare.</exception>
|
|
222
|
+
public async Task<SdkResponse> ExecuteAsync(
|
|
223
|
+
HttpMethod method,
|
|
224
|
+
string path,
|
|
225
|
+
IEnumerable<KeyValuePair<string, string>>? query = null,
|
|
226
|
+
IEnumerable<KeyValuePair<string, string>>? headers = null,
|
|
227
|
+
HttpContent? content = null,
|
|
228
|
+
IReadOnlyCollection<int>? expectStatuses = null,
|
|
229
|
+
CancellationToken cancellationToken = default)
|
|
230
|
+
{
|
|
231
|
+
using var request = new HttpRequestMessage(method, BuildUrl(path, query));
|
|
232
|
+
if (content is not null) request.Content = content;
|
|
233
|
+
|
|
234
|
+
if (_options.Headers is not null)
|
|
235
|
+
{
|
|
236
|
+
foreach (var entry in await _options.Headers(cancellationToken).ConfigureAwait(false))
|
|
237
|
+
{
|
|
238
|
+
request.Headers.TryAddWithoutValidation(entry.Key, entry.Value);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (headers is not null)
|
|
243
|
+
{
|
|
244
|
+
foreach (var entry in headers)
|
|
245
|
+
{
|
|
246
|
+
request.Headers.TryAddWithoutValidation(entry.Key, entry.Value);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
var message = await Client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
|
251
|
+
var bytes = await message.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
|
252
|
+
var response = new SdkResponse(message, bytes);
|
|
253
|
+
|
|
254
|
+
var status = response.Status;
|
|
255
|
+
if ((status < 200 || status > 299) && (expectStatuses is null || !expectStatuses.Contains(status)))
|
|
256
|
+
{
|
|
257
|
+
throw new SdkException(status, response.Text, message.Headers);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return response;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// <summary>Decode a JSON response body.</summary>
|
|
264
|
+
/// <exception cref="SdkException">When the body is JSON null.</exception>
|
|
265
|
+
public T ReadJson<T>(SdkResponse response)
|
|
266
|
+
{
|
|
267
|
+
var value = JsonSerializer.Deserialize<T>(response.Bytes, Json);
|
|
268
|
+
if (value is null)
|
|
269
|
+
{
|
|
270
|
+
throw new SdkException(response.Status, response.Text, response.Message.Headers, "Response body was null");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// <summary>
|
|
277
|
+
/// Read a response header the contract declares as required.
|
|
278
|
+
/// </summary>
|
|
279
|
+
/// <exception cref="SdkException">When the service did not send it, since the caller was promised a value.</exception>
|
|
280
|
+
public string RequireHeader(SdkResponse response, string name) =>
|
|
281
|
+
response.Header(name)
|
|
282
|
+
?? throw new SdkException(response.Status, response.Text, response.Message.Headers, $"Response is missing the required header '{name}'");
|
|
283
|
+
|
|
284
|
+
/// <summary>Join path segments onto the base URL. Each segment is already escaped.</summary>
|
|
285
|
+
public string Path(params string[] segments) => segments.Length == 0 ? string.Empty : "/" + string.Join("/", segments);
|
|
286
|
+
|
|
287
|
+
/// <summary>The escaped text form of a single value, for use as a path segment.</summary>
|
|
288
|
+
public string Segment<T>(T value) => Uri.EscapeDataString(ScalarText(JsonSerializer.SerializeToElement(value, Json)) ?? string.Empty);
|
|
289
|
+
|
|
290
|
+
/// <summary>
|
|
291
|
+
/// Every property of <paramref name="value"/> as a key and value pair. A list property repeats
|
|
292
|
+
/// its key; a null property is omitted.
|
|
293
|
+
/// </summary>
|
|
294
|
+
public IEnumerable<KeyValuePair<string, string>> Params<T>(T value)
|
|
295
|
+
{
|
|
296
|
+
if (value is null) yield break;
|
|
297
|
+
|
|
298
|
+
var element = JsonSerializer.SerializeToElement(value, Json);
|
|
299
|
+
if (element.ValueKind != JsonValueKind.Object) yield break;
|
|
300
|
+
|
|
301
|
+
foreach (var property in element.EnumerateObject())
|
|
302
|
+
{
|
|
303
|
+
if (property.Value.ValueKind == JsonValueKind.Array)
|
|
304
|
+
{
|
|
305
|
+
foreach (var item in property.Value.EnumerateArray())
|
|
306
|
+
{
|
|
307
|
+
if (ScalarText(item) is { } itemText) yield return new KeyValuePair<string, string>(property.Name, itemText);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (ScalarText(property.Value) is { } text)
|
|
311
|
+
{
|
|
312
|
+
yield return new KeyValuePair<string, string>(property.Name, text);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// <summary>A JSON body. Sent as a buffered string so the request carries a Content-Length.</summary>
|
|
318
|
+
public HttpContent JsonContent<T>(T value, string mediaType) =>
|
|
319
|
+
new StringContent(JsonSerializer.Serialize(value, Json), Encoding.UTF8, mediaType);
|
|
320
|
+
|
|
321
|
+
/// <summary>A form-encoded body, built from the same property walk as the query string.</summary>
|
|
322
|
+
public HttpContent FormContent<T>(T value) => new FormUrlEncodedContent(Params(value));
|
|
323
|
+
|
|
324
|
+
/// <summary>A multipart body.</summary>
|
|
325
|
+
public HttpContent MultipartContent(IEnumerable<SdkPart> parts)
|
|
326
|
+
{
|
|
327
|
+
var content = new MultipartFormDataContent();
|
|
328
|
+
foreach (var part in parts)
|
|
329
|
+
{
|
|
330
|
+
if (part.FileName is null) content.Add(part.Content, part.Name);
|
|
331
|
+
else content.Add(part.Content, part.Name, part.FileName);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return content;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/// <summary>A text body with an explicit mime.</summary>
|
|
338
|
+
public HttpContent TextContent(string value, string mediaType) => new StringContent(value, Encoding.UTF8, mediaType);
|
|
339
|
+
|
|
340
|
+
/// <summary>A binary body with an explicit mime.</summary>
|
|
341
|
+
public HttpContent BinaryContent(byte[] value, string mediaType)
|
|
342
|
+
{
|
|
343
|
+
var content = new ByteArrayContent(value);
|
|
344
|
+
content.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
|
|
345
|
+
return content;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// <summary>The text a scalar JSON value travels as outside a body, or null when it is absent.</summary>
|
|
349
|
+
public static string? ScalarText(JsonElement element) =>
|
|
350
|
+
element.ValueKind switch
|
|
351
|
+
{
|
|
352
|
+
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
|
353
|
+
JsonValueKind.String => element.GetString(),
|
|
354
|
+
JsonValueKind.True => "true",
|
|
355
|
+
JsonValueKind.False => "false",
|
|
356
|
+
_ => element.GetRawText(),
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
public void Dispose()
|
|
360
|
+
{
|
|
361
|
+
if (_ownsClient) Client.Dispose();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private string BuildUrl(string path, IEnumerable<KeyValuePair<string, string>>? query)
|
|
365
|
+
{
|
|
366
|
+
var builder = new StringBuilder(_options.BaseUrl.TrimEnd('/')).Append(path);
|
|
367
|
+
if (query is null) return builder.ToString();
|
|
368
|
+
|
|
369
|
+
var first = true;
|
|
370
|
+
foreach (var entry in query)
|
|
371
|
+
{
|
|
372
|
+
builder.Append(first ? '?' : '&');
|
|
373
|
+
first = false;
|
|
374
|
+
builder.Append(Uri.EscapeDataString(entry.Key)).Append('=').Append(Uri.EscapeDataString(entry.Value));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return builder.ToString();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
`;
|
|
381
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project file a generated SDK needs to build on its own.
|
|
3
|
+
*
|
|
4
|
+
* Emitted with `ifAbsent`, so it is created once and then belongs to the user: a project will add a
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the scaffold pins. One object so a bump is one edit.
|
|
11
|
+
*
|
|
12
|
+
* There is deliberately no dependency list to go with it: the generated SDK uses only
|
|
13
|
+
* `System.Text.Json` and `HttpClient` from the shared framework, so `dotnet build` restores with no
|
|
14
|
+
* NuGet feed reachable at all.
|
|
15
|
+
*/
|
|
16
|
+
export const SCAFFOLD_VERSIONS = {
|
|
17
|
+
targetFramework: 'net10.0',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate `<SdkName>.csproj`.
|
|
22
|
+
*
|
|
23
|
+
* `ImplicitUsings` is off because generated files carry an explicit `using` block of their own, and
|
|
24
|
+
* leaving it on would make the output depend on the SDK's implicit set rather than on what the
|
|
25
|
+
* generator wrote.
|
|
26
|
+
*/
|
|
27
|
+
export function generateCsproj(namespaceName: string, sdkName: string): string {
|
|
28
|
+
return `<!-- Created once by @contractkit/plugin-csharp. Yours to edit: it is never regenerated. -->
|
|
29
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
30
|
+
|
|
31
|
+
<PropertyGroup>
|
|
32
|
+
<TargetFramework>${SCAFFOLD_VERSIONS.targetFramework}</TargetFramework>
|
|
33
|
+
<Nullable>enable</Nullable>
|
|
34
|
+
<ImplicitUsings>disable</ImplicitUsings>
|
|
35
|
+
<RootNamespace>${namespaceName}</RootNamespace>
|
|
36
|
+
<AssemblyName>${sdkName}</AssemblyName>
|
|
37
|
+
</PropertyGroup>
|
|
38
|
+
|
|
39
|
+
</Project>
|
|
40
|
+
`;
|
|
41
|
+
}
|