Skip to content

Commit 1643323

Browse files
authored
feat(body): compress request bodies (#2311)
* feat(body): compress request bodies - Add `[Body(Compression = ...)]` so a method can send its body under a content coding. Refit compresses whatever the serializer produced and sets Content-Encoding, so the coding composes with every serialization method. - Add RefitSettings.RequestCompression for a client-wide default. A [Body] parameter naming its own coding overrides it, and None opts one method out. - Use the framework's GZipCompressedContent, BrotliCompressedContent and ZstandardCompressedContent on net11.0, and compress through GZipStream or BrotliStream below it. - Throw PlatformNotSupportedException for a coding this framework cannot produce rather than quietly sending the body uncompressed. gzip is always available, Brotli needs net8.0, Zstandard needs net11.0. Closes #2307 * feat(body): configure the request compressor directly - Add RefitSettings.RequestCompressionOptions so a client can set the compressor's own knobs - window size, strategy, a Zstandard dictionary - which a CompressionLevel cannot express. - Options set for a coding replace the level for that coding; the codings left unset still compress by level. - The options types arrived with .NET 9.0, and Zstandard's with .NET 11.0, so the surface only exists from net9.0 onward. * test(body): cover the request compression paths - Add generator tests for the coding and level a [Body] parameter declares, including the None case that emits no call and a level declared without a coding. - Add direct tests for the coding-to-token map and for the wrap and the compressor refusing a value that names no coding. - Add options tests for Brotli and Zstandard, so every coding that accepts its own compressor settings is exercised on the targets that have them. - Read an erroneous named argument through its TypedConstant kind rather than its value, so a value the compiler already rejected leaves the defaults in place instead of being read as an int.
1 parent 755b8c3 commit 1643323

35 files changed

Lines changed: 1532 additions & 9 deletions

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,65 @@ var settings = new RefitSettings(new SystemTextJsonContentSerializer(options))
10131013
Both modes require the content serializer to implement `ISynchronousContentSerializer` (the default
10141014
`SystemTextJsonContentSerializer` does); otherwise the body falls back to the default asynchronous serialization.
10151015

1016+
### Compressing the request body
1017+
1018+
Set `Compression` on `[Body]` to send the body under a content coding. Refit compresses whatever the serializer
1019+
produced and sets `Content-Encoding` to match, so the coding composes with every serialization method:
1020+
1021+
```csharp
1022+
public interface IUploadApi
1023+
{
1024+
[Post("/measurements")]
1025+
Task Upload([Body(Compression = RequestCompression.GZip)] Measurement[] batch);
1026+
1027+
[Post("/measurements")]
1028+
Task UploadSmallest([Body(
1029+
Compression = RequestCompression.Brotli,
1030+
CompressionLevel = CompressionLevel.SmallestSize)] Measurement[] batch);
1031+
}
1032+
```
1033+
1034+
To compress every body from one client, set it on the settings instead. A `[Body]` parameter naming its own coding
1035+
overrides that, and `RequestCompression.None` on the parameter opts a single method out:
1036+
1037+
```csharp
1038+
var settings = new RefitSettings
1039+
{
1040+
RequestCompression = RequestCompression.GZip,
1041+
RequestCompressionLevel = CompressionLevel.Optimal,
1042+
};
1043+
```
1044+
1045+
For knobs a level cannot express — window size, strategy, a Zstandard dictionary — set the compressor's own options.
1046+
Options set for a coding replace the level for that coding; the codings left unset still compress by level:
1047+
1048+
```csharp
1049+
var settings = new RefitSettings
1050+
{
1051+
RequestCompression = RequestCompression.Brotli,
1052+
RequestCompressionOptions = new()
1053+
{
1054+
Brotli = new() { Quality = 9, WindowLog2 = 22 },
1055+
GZip = new() { CompressionLevel = 6 },
1056+
},
1057+
};
1058+
```
1059+
1060+
`RequestCompressionOptions` needs .NET 9.0 or later, where `ZLibCompressionOptions` and `BrotliCompressionOptions`
1061+
were introduced; its `Zstandard` property needs .NET 11.0.
1062+
1063+
There is no negotiation for a compressed request body, so only turn this on against a server you know accepts one.
1064+
The compressed length is unknown until the body has been written, so these requests are sent chunked.
1065+
1066+
Codings are not available on every target framework, and asking for one the running framework cannot produce throws
1067+
`PlatformNotSupportedException` when the request is built rather than quietly sending the body uncompressed:
1068+
1069+
| Coding | `Content-Encoding` | Available on |
1070+
| --- | --- | --- |
1071+
| `RequestCompression.GZip` | `gzip` | every target |
1072+
| `RequestCompression.Brotli` | `br` | .NET 8.0 and later |
1073+
| `RequestCompression.Zstandard` | `zstd` | .NET 11.0 and later |
1074+
10161075
For instance, here is how to create a new `RefitSettings` instance using the `Newtonsoft.Json`-based serializer (you'll
10171076
also need to add a `PackageReference` to `Refit.Newtonsoft.Json`):
10181077

src/InterfaceStubGenerator.Shared/Emitter.Inline.Content.cs

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ internal static string BuildInlineContent(
2525
var requestLocal = plan.RequestLocal;
2626
var settingsLocal = plan.SettingsLocal;
2727
var bodyParameter = plan.BodyParameter!.Value;
28+
var compression = BuildContentCompression(bodyParameter, requestLocal, settingsLocal, bodyIndent);
2829
if (bodyParameter.BodySerializationMethod == "UrlEncoded")
2930
{
3031
if (IsUnrollableFormBody(bodyParameter))
3132
{
32-
return BuildInlineFormUnroll(bodyParameter, requestLocal, supportsNullable, emission, plan.Locals);
33+
return BuildInlineFormUnroll(bodyParameter, requestLocal, supportsNullable, emission, plan.Locals)
34+
+ compression;
3335
}
3436

35-
return formFieldsFieldName is not null
37+
return (formFieldsFieldName is not null
3638
? $$"""
3739
{{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<{{bodyParameter.Type}}>(
3840
{{bodyIndent}} {{settingsLocal}},
@@ -45,7 +47,7 @@ internal static string BuildInlineContent(
4547
{{bodyIndent}} {{settingsLocal}},
4648
{{bodyIndent}} @{{bodyParameter.Name}});
4749
48-
""";
50+
""") + compression;
4951
}
5052

5153
if (bodyParameter.BodySerializationMethod == "JsonLines")
@@ -55,7 +57,7 @@ internal static string BuildInlineContent(
5557
{{bodyIndent}} {{settingsLocal}},
5658
{{bodyIndent}} @{{bodyParameter.Name}});
5759
58-
""";
60+
""" + compression;
5961
}
6062

6163
var streamBodyExpression = BuildStreamBodyExpression(bodyParameter, settingsLocal);
@@ -68,7 +70,37 @@ internal static string BuildInlineContent(
6870
{{bodyIndent}} {{serializationMethodExpression}},
6971
{{bodyIndent}} {{streamBodyExpression}});
7072
71-
""";
73+
""" + compression;
74+
}
75+
76+
/// <summary>Emits the content-coding wrap for a body, or nothing when no coding can apply.</summary>
77+
/// <param name="bodyParameter">The body parameter model.</param>
78+
/// <param name="requestLocal">The generated request message local name.</param>
79+
/// <param name="settingsLocal">The generated settings local name.</param>
80+
/// <param name="bodyIndent">The method body indentation.</param>
81+
/// <returns>The wrap statement, or an empty string.</returns>
82+
/// <remarks>
83+
/// A body that declares <c>None</c> can never be compressed, so nothing is emitted for it. Every other body emits
84+
/// the wrap, because <c>Default</c> resolves against <c>RefitSettings</c> at request time.
85+
/// </remarks>
86+
internal static string BuildContentCompression(
87+
in RequestParameterModel bodyParameter,
88+
string requestLocal,
89+
string settingsLocal,
90+
string bodyIndent)
91+
{
92+
var compression = bodyParameter.Compression ?? "Default";
93+
94+
return compression == "None"
95+
? string.Empty
96+
: $$"""
97+
{{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CompressBodyContent(
98+
{{bodyIndent}} {{requestLocal}}.Content,
99+
{{bodyIndent}} {{settingsLocal}},
100+
{{bodyIndent}} global::Refit.RequestCompression.{{compression}},
101+
{{bodyIndent}} global::System.IO.Compression.CompressionLevel.{{bodyParameter.CompressionLevel ?? "Optimal"}});
102+
103+
""";
72104
}
73105

74106
/// <summary>Emits straight-line form-url-encoded body serialization for an all-scalar body, mirroring the descriptor

src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ internal readonly record struct RequestParameterModel(
6060
/// placeholder with a formatted property value.</summary>
6161
internal ImmutableEquatableArray<PathObjectBindingModel>? PathObjectBindings { get; init; }
6262

63+
/// <summary>Gets the <c>Refit.RequestCompression</c> member name the body declared, or <see langword="null"/> for a
64+
/// parameter that is not a body. <c>Default</c> means the request takes its coding from the settings.</summary>
65+
internal string? Compression { get; init; }
66+
67+
/// <summary>Gets the <c>System.IO.Compression.CompressionLevel</c> member name the body declared, or
68+
/// <see langword="null"/> for a parameter that is not a body.</summary>
69+
internal string? CompressionLevel { get; init; }
70+
6371
/// <summary>Gets the multipart part descriptor when this parameter contributes a <c>[Multipart]</c> form part —
6472
/// set for <see cref="RequestParameterKind.MultipartPart"/> parameters and <see langword="null"/> otherwise.</summary>
6573
internal MultipartPartModel? MultipartPart { get; init; }

src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,14 +270,36 @@ internal static BodyAttributeInfo ParseBodyAttribute(AttributeData attribute)
270270
}
271271
}
272272

273+
var compression = "Default";
274+
var compressionLevel = "Optimal";
275+
276+
foreach (var named in attribute.NamedArguments)
277+
{
278+
// Both arguments are enum-typed, so anything else is a value the compiler already rejected; leave the
279+
// defaults in place rather than reading a constant that is not there.
280+
if (named.Value.Kind != TypedConstantKind.Enum)
281+
{
282+
continue;
283+
}
284+
285+
if (named.Key == "Compression")
286+
{
287+
compression = GetRequestCompressionName((int)named.Value.Value!);
288+
}
289+
else if (named.Key == "CompressionLevel")
290+
{
291+
compressionLevel = GetCompressionLevelName((int)named.Value.Value!);
292+
}
293+
}
294+
273295
var bufferMode = buffered switch
274296
{
275297
true => BodyBufferMode.Buffered,
276298
false => BodyBufferMode.Streaming,
277299
_ => BodyBufferMode.Settings
278300
};
279301

280-
return new(serializationMethod, bufferMode);
302+
return new(serializationMethod, bufferMode, compression, compressionLevel);
281303
}
282304

283305
/// <summary>Tries to parse a body serialization method constructor argument.</summary>
@@ -300,9 +322,13 @@ internal static bool TryGetBodySerializationMethodName(in TypedConstant argument
300322
/// <summary>Parsed body attribute data.</summary>
301323
/// <param name="SerializationMethod">The body serialization method name.</param>
302324
/// <param name="BufferMode">The body buffering mode.</param>
325+
/// <param name="Compression">The <c>RequestCompression</c> member name the body declared.</param>
326+
/// <param name="CompressionLevel">The <c>CompressionLevel</c> member name the body declared.</param>
303327
internal readonly record struct BodyAttributeInfo(
304328
string SerializationMethod,
305-
BodyBufferMode BufferMode);
329+
BodyBufferMode BufferMode,
330+
string Compression,
331+
string CompressionLevel);
306332

307333
/// <summary>Form-relevant data parsed from a <c>[Query]</c> attribute on a parameter or body property.</summary>
308334
/// <param name="Delimiter">The delimiter combined with the prefix, or <see langword="null"/> when no

src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ internal static partial class Parser
1717
/// <summary>The underlying value for <c>BodySerializationMethod.JsonLines</c>.</summary>
1818
private const int BodySerializationJsonLines = 4;
1919

20+
/// <summary>The underlying value for <c>RequestCompression.None</c>.</summary>
21+
private const int RequestCompressionNone = 1;
22+
23+
/// <summary>The underlying value for <c>RequestCompression.GZip</c>.</summary>
24+
private const int RequestCompressionGZip = 2;
25+
26+
/// <summary>The underlying value for <c>RequestCompression.Brotli</c>.</summary>
27+
private const int RequestCompressionBrotli = 3;
28+
29+
/// <summary>The underlying value for <c>RequestCompression.Zstandard</c>.</summary>
30+
private const int RequestCompressionZstandard = 4;
31+
32+
/// <summary>The underlying value for <c>CompressionLevel.Fastest</c>.</summary>
33+
private const int CompressionLevelFastest = 1;
34+
35+
/// <summary>The underlying value for <c>CompressionLevel.NoCompression</c>.</summary>
36+
private const int CompressionLevelNoCompression = 2;
37+
38+
/// <summary>The underlying value for <c>CompressionLevel.SmallestSize</c>.</summary>
39+
private const int CompressionLevelSmallestSize = 3;
40+
2041
/// <summary>Determines whether a symbol's containing namespace equals a dotted namespace name, without allocating.</summary>
2142
/// <param name="symbol">The symbol whose containing namespace is compared, or null.</param>
2243
/// <param name="dottedNamespace">The expected namespace as a dotted name, for example <c>System.Threading.Tasks</c>.</param>
@@ -236,6 +257,31 @@ internal static string GetBodySerializationMethodName(int value) =>
236257
_ => string.Empty
237258
};
238259

260+
/// <summary>Gets the Refit request compression enum member name for an underlying value.</summary>
261+
/// <param name="value">The enum value.</param>
262+
/// <returns>The enum member name, or <c>Default</c> for a value this generator does not know.</returns>
263+
internal static string GetRequestCompressionName(int value) =>
264+
value switch
265+
{
266+
RequestCompressionNone => "None",
267+
RequestCompressionGZip => "GZip",
268+
RequestCompressionBrotli => "Brotli",
269+
RequestCompressionZstandard => "Zstandard",
270+
_ => "Default"
271+
};
272+
273+
/// <summary>Gets the <c>System.IO.Compression.CompressionLevel</c> member name for an underlying value.</summary>
274+
/// <param name="value">The enum value.</param>
275+
/// <returns>The enum member name, or <c>Optimal</c> for a value this generator does not know.</returns>
276+
internal static string GetCompressionLevelName(int value) =>
277+
value switch
278+
{
279+
CompressionLevelFastest => "Fastest",
280+
CompressionLevelNoCompression => "NoCompression",
281+
CompressionLevelSmallestSize => "SmallestSize",
282+
_ => "Optimal"
283+
};
284+
239285
/// <summary>Determines whether all body bindings are supported by the initial inline emitter.</summary>
240286
/// <param name="parameters">The parsed request parameter models.</param>
241287
/// <returns><see langword="true"/> when every body binding is supported.</returns>

src/InterfaceStubGenerator.Shared/Parser.Request.ParameterKinds.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ internal static bool TryParseBodyParameter(
195195
string.Empty,
196196
string.Empty,
197197
bodyInfo.SerializationMethod,
198-
bodyInfo.BufferMode) { FormFields = formFields, };
198+
bodyInfo.BufferMode) { FormFields = formFields, Compression = bodyInfo.Compression, CompressionLevel = bodyInfo.CompressionLevel, };
199199
return true;
200200
}
201201

src/Refit.Reflection/RequestBuilderImplementation.Payload.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ internal partial class RequestBuilderImplementation
1818
/// <param name="ret">The request message to populate.</param>
1919
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
2020
internal void AddBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret)
21+
{
22+
SelectBodyContent(restMethod, param, ret);
23+
24+
if (ret.Content is null)
25+
{
26+
return;
27+
}
28+
29+
ret.Content = GeneratedRequestRunner.CompressBodyContent(
30+
ret.Content,
31+
_settings,
32+
restMethod.BodyCompression,
33+
restMethod.BodyCompressionLevel);
34+
}
35+
36+
/// <summary>Sets the request content from the body parameter, before any content coding is applied.</summary>
37+
/// <param name="restMethod">The rest method being invoked.</param>
38+
/// <param name="param">The body argument value.</param>
39+
/// <param name="ret">The request message to populate.</param>
40+
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
41+
internal void SelectBodyContent(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret)
2142
{
2243
if (param is HttpContent httpContentParam)
2344
{

src/Refit.Reflection/RestMethodInfoInternal.AttributeReading.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,19 @@ internal static (BodyAttribute? Attribute, int Index, bool HasMultiple) FindBody
218218
return (bodyAttribute, bodyParameterIndex, false);
219219
}
220220

221+
/// <summary>Reads the content coding an explicit <see cref="BodyAttribute"/> declared.</summary>
222+
/// <param name="sets">The classified attribute set for each parameter.</param>
223+
/// <returns>The declared coding and level. An implicit body declares neither, so it follows the settings.</returns>
224+
internal static (RequestCompression Compression, System.IO.Compression.CompressionLevel Level) FindBodyCompression(
225+
ParameterAttributeSet[] sets)
226+
{
227+
var (bodyAttribute, _, _) = FindBodyAttribute(sets);
228+
229+
return bodyAttribute is null
230+
? (RequestCompression.Default, System.IO.Compression.CompressionLevel.Optimal)
231+
: (bodyAttribute.Compression, bodyAttribute.CompressionLevel);
232+
}
233+
221234
/// <summary>Finds the parameter that carries the authorization value.</summary>
222235
/// <param name="sets">The classified attribute set for each parameter.</param>
223236
/// <returns>The authorization parameter information, or null when there is no authorize parameter.</returns>

src/Refit.Reflection/RestMethodInfoInternal.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ internal RestMethodInfoInternal(
9393
UrlParameterInfo = ResolveUrlParameter(ParameterInfoArray, parameterAttributeSets, RelativePath);
9494
(ParameterMap, FragmentPath) = BuildParameterMap(RelativePath, ParameterInfoArray, RefitSettings.AllowUnmatchedRouteParameters || methodInfo.IsGenericMethodDefinition);
9595
BodyParameterInfo = FindBodyParameter(ParameterInfoArray, parameterAttributeSets, IsMultipart, hma.Method);
96+
(BodyCompression, BodyCompressionLevel) = FindBodyCompression(parameterAttributeSets);
9697
AuthorizeParameterInfo = FindAuthorizationParameter(parameterAttributeSets);
9798

9899
Headers = ParseHeaders(targetInterface, methodInfo);
@@ -160,6 +161,12 @@ internal RestMethodInfoInternal(
160161
/// <summary>Gets the body parameter information, or null when there is no body parameter.</summary>
161162
internal Tuple<BodySerializationMethod, bool, int>? BodyParameterInfo { get; }
162163

164+
/// <summary>Gets the content coding the <c>[Body]</c> parameter declared, <c>Default</c> to follow the settings.</summary>
165+
internal RequestCompression BodyCompression { get; }
166+
167+
/// <summary>Gets how hard <see cref="BodyCompression"/> compresses.</summary>
168+
internal System.IO.Compression.CompressionLevel BodyCompressionLevel { get; }
169+
163170
/// <summary>Gets the authorization parameter information, or null when there is no authorize parameter.</summary>
164171
internal Tuple<string, int>? AuthorizeParameterInfo { get; }
165172

src/Refit/BodyAttribute.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,16 @@ public BodyAttribute(BodySerializationMethod serializationMethod) =>
5454
/// </value>
5555
public BodySerializationMethod SerializationMethod { get; } =
5656
BodySerializationMethod.Default;
57+
58+
/// <summary>Gets or sets the content coding applied to this body, overriding <see cref="RefitSettings.RequestCompression"/>.</summary>
59+
/// <remarks>
60+
/// Leave unset to follow the settings. <see cref="RequestCompression.None"/> opts this method out of a coding the
61+
/// settings turned on. Not every coding exists on every target framework - see <see cref="RequestCompression"/>.
62+
/// </remarks>
63+
public RequestCompression Compression { get; set; } = RequestCompression.Default;
64+
65+
/// <summary>Gets or sets how hard <see cref="Compression"/> compresses (defaults to <see cref="System.IO.Compression.CompressionLevel.Optimal"/>).</summary>
66+
/// <remarks>Read only when <see cref="Compression"/> names a coding; otherwise the settings supply the level too.</remarks>
67+
public System.IO.Compression.CompressionLevel CompressionLevel { get; set; } =
68+
System.IO.Compression.CompressionLevel.Optimal;
5769
}

0 commit comments

Comments
 (0)