-
-
Notifications
You must be signed in to change notification settings - Fork 784
Expand file tree
/
Copy pathGeneratedQueryStringBuilder.cs
More file actions
388 lines (338 loc) · 18.7 KB
/
Copy pathGeneratedQueryStringBuilder.cs
File metadata and controls
388 lines (338 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
using System.Diagnostics;
namespace Refit;
/// <summary>
/// Appends query parameters to a relative request path without reflection, matching the escaping, ordering,
/// null-omission and collection-format semantics of the reflection request builder. Used by source-generated
/// request construction; the API is also callable directly by hand-written AOT-friendly clients.
/// </summary>
/// <remarks>
/// Values must already be formatted (see <see cref="GeneratedRequestRunner.FormatInvariant{T}(T, string?)"/> and
/// <see cref="IUrlParameterFormatter"/>); a <see langword="null"/> formatted value omits its parameter. Query keys
/// and values are escaped with <see cref="Uri.EscapeDataString(string)"/> unless a call passes
/// <c>preEncoded: true</c> (the <see cref="EncodedAttribute"/> contract).
/// </remarks>
[System.Diagnostics.DebuggerDisplay("{ToString(),nq}")]
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
public ref struct GeneratedQueryStringBuilder
{
/// <summary>The extra capacity reserved beyond the path when the first parameter is appended.</summary>
private const int InitialQueryCapacity = 128;
#if NET6_0_OR_GREATER
/// <summary>The stack buffer size for span-formatting a single query value; larger renderings grow a rented buffer.</summary>
private const int FormatBufferLength = 128;
/// <summary>The factor by which the rented span-formatting buffer grows when a value overflows the current buffer.</summary>
private const int BufferGrowthFactor = 2;
#endif
/// <summary>The relative path the query string is appended to.</summary>
private readonly string _relativePath;
/// <summary>The accumulating path plus query text; unused until the first parameter is appended.</summary>
private ValueStringBuilder _text;
/// <summary>The accumulating joined collection value while inside a non-multi collection.</summary>
private ValueStringBuilder _joinedValues;
/// <summary>The key of the collection currently being appended, or null outside a collection.</summary>
private string? _collectionKey;
/// <summary>The delimiter between joined collection values.</summary>
private char _collectionDelimiter;
/// <summary>Whether the current collection renders one <c>key=value</c> pair per element.</summary>
private bool _collectionIsMulti;
/// <summary>Whether the current collection is caller-encoded.</summary>
private bool _collectionPreEncoded;
/// <summary>The number of values appended to the current joined collection.</summary>
private int _collectionValueCount;
/// <summary>Whether the path or an appended parameter already established a query string.</summary>
private bool _hasQuery;
/// <summary>Whether any parameter has been appended, requiring <see cref="Build"/> to materialize new text.</summary>
private bool _hasAppended;
/// <summary>Initializes a new instance of the <see cref="GeneratedQueryStringBuilder"/> struct.</summary>
/// <param name="relativePath">The relative path, whose template query string (if any) is preserved in front
/// of appended parameters. Dynamic path segments must already be escaped.</param>
public GeneratedQueryStringBuilder(string relativePath)
: this(relativePath, StringHelpers.Contains(relativePath, '?'))
{
}
/// <summary>Initializes a new instance of the <see cref="GeneratedQueryStringBuilder"/> struct with a known query state.</summary>
/// <param name="relativePath">The relative path, whose template query string (if any) is preserved in front of
/// appended parameters. Dynamic path segments must already be escaped.</param>
/// <param name="hasQuery">Whether <paramref name="relativePath"/> already contains a <c>?</c>; the generator passes
/// the compile-time answer so the path is not rescanned per call.</param>
public GeneratedQueryStringBuilder(string relativePath, bool hasQuery)
{
_relativePath = relativePath;
_text = default;
_joinedValues = default;
_hasQuery = hasQuery;
}
/// <summary>Appends one <c>key=value</c> query parameter.</summary>
/// <param name="name">The query key.</param>
/// <param name="value">The formatted value; the parameter is omitted when this is <see langword="null"/>.</param>
/// <param name="preEncoded">Whether the key and value are caller-encoded and appended verbatim.</param>
public void Add(string name, string? value, bool preEncoded)
{
if (value is null)
{
return;
}
AppendPair(name, value, keyEscaped: false, preEncoded);
}
/// <summary>Appends one <c>key=value</c> query parameter whose key the generator already escaped.</summary>
/// <param name="name">The pre-escaped (or caller-encoded) query key, appended verbatim.</param>
/// <param name="value">The formatted value; the parameter is omitted when this is <see langword="null"/>.</param>
/// <param name="preEncoded">Whether the value is caller-encoded and appended verbatim.</param>
/// <remarks>Used for compile-time-constant keys, which the generator escapes once instead of on every call.</remarks>
public void AddPreEscapedKey(string name, string? value, bool preEncoded)
{
if (value is null)
{
return;
}
AppendPair(name, value, keyEscaped: true, preEncoded);
}
#if NET6_0_OR_GREATER
/// <summary>Appends one <c>key=value</c> query parameter, formatting an <see cref="ISpanFormattable"/> value straight
/// into the query buffer with no intermediate formatted string.</summary>
/// <typeparam name="T">The span-formattable value type.</typeparam>
/// <param name="name">The query key.</param>
/// <param name="value">The value to render; the generator routes only non-null values here.</param>
/// <param name="format">The compile-time format from <c>[Query(Format = ...)]</c>, or null.</param>
/// <param name="preEncoded">Whether the key and value are caller-encoded and appended verbatim.</param>
/// <remarks>The value is rendered invariant and escaped to produce exactly what <see cref="Add"/> yields for the
/// same rendered string. On targets without <c>Uri.EscapeDataString(ReadOnlySpan<char>)</c> the generator only
/// routes a URL-unreserved integer here, whose formatted span (digits and an optional <c>-</c>) needs no escaping.</remarks>
public void AddFormatted<T>(string name, T value, string? format, bool preEncoded)
where T : ISpanFormattable =>
AppendFormattedPair(name, value, format, keyEscaped: false, preEncoded);
/// <summary>Appends one pre-escaped-key <c>key=value</c> pair, formatting a span-formattable value into the buffer.</summary>
/// <typeparam name="T">The span-formattable value type.</typeparam>
/// <param name="name">The pre-escaped (or caller-encoded) query key, appended verbatim.</param>
/// <param name="value">The value to render; the generator routes only non-null values here.</param>
/// <param name="format">The compile-time format from <c>[Query(Format = ...)]</c>, or null.</param>
/// <param name="preEncoded">Whether the value is caller-encoded and appended verbatim.</param>
public void AddFormattedPreEscapedKey<T>(string name, T value, string? format, bool preEncoded)
where T : ISpanFormattable =>
AppendFormattedPair(name, value, format, keyEscaped: true, preEncoded);
#endif
/// <summary>Appends one valueless query flag (<c>?name</c>).</summary>
/// <param name="name">The formatted flag name; the flag is omitted when this is <see langword="null"/>.</param>
/// <param name="preEncoded">Whether the name is caller-encoded and appended verbatim.</param>
/// <remarks>The flag name is the parameter's runtime value (a <c>[QueryName]</c> value or collection element), so it
/// is escaped here rather than pre-escaped by the generator.</remarks>
public void AddFlag(string? name, bool preEncoded)
{
if (name is null)
{
return;
}
AppendSeparator();
AppendQueryComponent(name, preEncoded);
}
/// <summary>Starts a collection-valued parameter fed by <see cref="AddCollectionValue(string?)"/> calls and finished by <see cref="EndCollection"/>.</summary>
/// <param name="name">The query key.</param>
/// <param name="collectionFormat">The resolved collection format (an explicit attribute value, or the
/// <see cref="RefitSettings.CollectionFormat"/> default).</param>
/// <param name="preEncoded">Whether the key and values are caller-encoded and appended verbatim.</param>
public void BeginCollection(string name, CollectionFormat collectionFormat, bool preEncoded)
{
Debug.Assert(_collectionKey is null, "BeginCollection must not be nested.");
_collectionKey = name;
_collectionIsMulti = collectionFormat == CollectionFormat.Multi;
_collectionPreEncoded = preEncoded;
_collectionValueCount = 0;
_collectionDelimiter = collectionFormat switch
{
CollectionFormat.Ssv => ' ',
CollectionFormat.Tsv => '\t',
CollectionFormat.Pipes => '|',
_ => ','
};
}
/// <summary>Appends one formatted element of the current collection.</summary>
/// <param name="value">The formatted element value; under <see cref="CollectionFormat.Multi"/> a
/// <see langword="null"/> element is omitted, otherwise it joins as an empty value.</param>
public void AddCollectionValue(string? value)
{
Debug.Assert(_collectionKey is not null, "AddCollectionValue requires BeginCollection.");
if (_collectionIsMulti)
{
if (value is not null)
{
AppendPair(_collectionKey!, value, keyEscaped: false, _collectionPreEncoded);
}
return;
}
if (_collectionValueCount > 0)
{
_joinedValues.Append(_collectionDelimiter);
}
_collectionValueCount++;
_joinedValues.Append(value);
}
#if NET6_0_OR_GREATER
/// <summary>Appends one <see cref="ISpanFormattable"/> element of the current collection, formatting it straight into
/// the query buffer with no intermediate formatted string.</summary>
/// <typeparam name="T">The span-formattable element type.</typeparam>
/// <param name="value">The element to render; the generator routes only non-null values here.</param>
/// <remarks>The element is rendered invariant with no format. Under <see cref="CollectionFormat.Multi"/> it becomes
/// its own escaped <c>key=value</c> pair; otherwise it joins into the value that <see cref="EndCollection"/> escapes
/// as a whole, so the joined element is appended unescaped exactly like <see cref="AddCollectionValue"/>.</remarks>
public void AddCollectionValueFormatted<T>(T value)
where T : ISpanFormattable
{
Debug.Assert(_collectionKey is not null, "AddCollectionValueFormatted requires BeginCollection.");
if (_collectionIsMulti)
{
AppendFormattedPair(_collectionKey!, value, null, keyEscaped: false, _collectionPreEncoded);
return;
}
if (_collectionValueCount > 0)
{
_joinedValues.Append(_collectionDelimiter);
}
_collectionValueCount++;
AppendFormattedValue(ref _joinedValues, value, null, escape: false);
}
#endif
/// <summary>Finishes the current collection, emitting the joined <c>key=value</c> pair for non-multi formats.</summary>
public void EndCollection()
{
Debug.Assert(_collectionKey is not null, "EndCollection requires BeginCollection.");
if (!_collectionIsMulti)
{
// A joined collection always emits its pair, even when the collection was empty (key=),
// matching the reflection request builder.
AppendPair(_collectionKey!, _joinedValues.ToString(), keyEscaped: false, _collectionPreEncoded);
}
_collectionKey = null;
}
/// <summary>Builds the final relative path with the appended query string and releases pooled buffers.</summary>
/// <returns>The relative path, unchanged when no parameter was appended.</returns>
public string Build()
{
_joinedValues.Dispose();
if (!_hasAppended)
{
_text.Dispose();
return _relativePath;
}
return _text.ToString();
}
#if NET6_0_OR_GREATER
/// <summary>Formats a span-formattable value into <paramref name="buffer"/>, growing a rented buffer until the value fits.</summary>
/// <typeparam name="T">The span-formattable value type.</typeparam>
/// <param name="value">The value to render.</param>
/// <param name="buffer">The target buffer, replaced with a larger rented buffer when the value overflows it.</param>
/// <param name="rented">The rented buffer to grow and return to the pool, or null while the stack buffer is in use.</param>
/// <param name="format">The compile-time format, or null for the default rendering.</param>
/// <returns>The number of characters written into <paramref name="buffer"/>.</returns>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] // The grow back-edge only fires for a value larger than the stack buffer; the compiler's loop second-jump stays unreachable in practice.
internal static int FormatWithGrowth<T>(T value, ref Span<char> buffer, ref char[]? rented, string? format)
where T : ISpanFormattable
{
int written;
while (!value.TryFormat(buffer, out written, format.AsSpan(), System.Globalization.CultureInfo.InvariantCulture))
{
if (rented is not null)
{
System.Buffers.ArrayPool<char>.Shared.Return(rented);
}
rented = System.Buffers.ArrayPool<char>.Shared.Rent(buffer.Length * BufferGrowthFactor);
buffer = rented;
}
return written;
}
/// <summary>Formats a span-formattable value into a stack buffer (growing a rented buffer when it overflows) and
/// appends it to the target, escaping the formatted span in place when requested.</summary>
/// <typeparam name="T">The span-formattable value type.</typeparam>
/// <param name="target">The buffer receiving the rendered value.</param>
/// <param name="value">The value to render.</param>
/// <param name="format">The compile-time format, or null for the default rendering.</param>
/// <param name="escape">Whether the formatted span is URI-data-escaped before it is appended.</param>
internal static void AppendFormattedValue<T>(ref ValueStringBuilder target, T value, string? format, bool escape)
where T : ISpanFormattable
{
Span<char> buffer = stackalloc char[FormatBufferLength];
char[]? rented = null;
try
{
var written = FormatWithGrowth(value, ref buffer, ref rented, format);
var formatted = (ReadOnlySpan<char>)buffer[..written];
if (escape)
{
// Percent-encode straight into the builder with no intermediate escaped string, on every target
// framework (the span overload of Uri.EscapeDataString only exists on net9+).
StringHelpers.AppendUriDataEscaped(ref target, formatted);
return;
}
// Copy into a reserved slice so the stack buffer is never captured by the builder (ref-safety), matching a
// verbatim span append with no intermediate string.
formatted.CopyTo(target.AppendSpan(written));
}
finally
{
if (rented is not null)
{
System.Buffers.ArrayPool<char>.Shared.Return(rented);
}
}
}
#endif
/// <summary>Appends the <c>?</c> or <c>&</c> separator, materializing the text buffer on first use.</summary>
internal void AppendSeparator()
{
if (!_hasAppended)
{
_text.EnsureCapacity(_relativePath.Length + InitialQueryCapacity);
_text.Append(_relativePath);
_hasAppended = true;
}
_text.Append(_hasQuery ? '&' : '?');
_hasQuery = true;
}
/// <summary>Appends one <c>key=value</c> pair with the configured escaping.</summary>
/// <param name="name">The query key.</param>
/// <param name="value">The non-null formatted value.</param>
/// <param name="keyEscaped">Whether the key is already escaped by the generator and appended verbatim.</param>
/// <param name="preEncoded">Whether the value (and, when not <paramref name="keyEscaped"/>, the key) is caller-encoded.</param>
internal void AppendPair(string name, string value, bool keyEscaped, bool preEncoded)
{
AppendSeparator();
AppendQueryComponent(name, keyEscaped || preEncoded);
_text.Append('=');
AppendQueryComponent(value, preEncoded);
}
#if NET6_0_OR_GREATER
/// <summary>Appends one <c>key=value</c> pair, formatting a span-formattable value straight into the buffer.</summary>
/// <typeparam name="T">The span-formattable value type.</typeparam>
/// <param name="name">The query key.</param>
/// <param name="value">The value to render.</param>
/// <param name="format">The compile-time format, or null.</param>
/// <param name="keyEscaped">Whether the key is already escaped by the generator and appended verbatim.</param>
/// <param name="preEncoded">Whether the value (and, when not <paramref name="keyEscaped"/>, the key) is caller-encoded.</param>
internal void AppendFormattedPair<T>(string name, T value, string? format, bool keyEscaped, bool preEncoded)
where T : ISpanFormattable
{
AppendSeparator();
AppendQueryComponent(name, keyEscaped || preEncoded);
_text.Append('=');
AppendFormattedValue(ref _text, value, format, escape: !preEncoded);
}
#endif
/// <summary>Appends one query key or value, percent-encoding directly into the query buffer when required.</summary>
/// <param name="value">The query component to append.</param>
/// <param name="appendVerbatim">Whether the component is already encoded and can be copied verbatim.</param>
private void AppendQueryComponent(string value, bool appendVerbatim)
{
if (appendVerbatim)
{
_text.Append(value);
return;
}
#if NET8_0_OR_GREATER
StringHelpers.AppendUriDataEscaped(ref _text, value.AsSpan());
#else
_text.Append(StringHelpers.EscapeDataString(value));
#endif
}
}