diff --git a/docs/docs/configure-headers.md b/docs/docs/configure-headers.md index ebe415f..d08b41d 100644 --- a/docs/docs/configure-headers.md +++ b/docs/docs/configure-headers.md @@ -83,6 +83,30 @@ Use the bulk overloads when you already have headers in a collection (e.g. from ::: +## Reserved Headers + +FluentHttpClient intentionally restricts a small set of HTTP headers that are controlled by the underlying `HttpClient` and its transport layers. These headers define wire-level framing and routing behavior, and overriding them can produce ambiguous requests, protocol violations, or security issues. + +Because of this, the fluent header extensions do **not** allow setting the following headers: + +* `Host` +* `Content-Length` +* `Transfer-Encoding` + +These values are determined automatically based on the request URI, the configured `HttpContent`, and the negotiated HTTP version. Preventing them from being set through the fluent API helps avoid accidental misuse - such as combining `Content-Length` with `Transfer-Encoding: chunked` - while keeping request construction predictable. + +### Advanced Usage + +This restriction only applies to the high-level fluent extensions. If advanced scenarios require manual control of these headers, you can still modify the underlying `HttpRequestMessage` using a configuration delegate (for example, via [`When`](./conditional-configuration.md) with an always-true bool or predicate). This opt-in approach allows experienced users to take full control without exposing casual users to common footguns. + +In short, the fluent API keeps the safe path safe, while still leaving the door open for expert customization or tom-foolery when needed. + +:::tip Indirect Control + +When you need indirect control over `Content-Length` or chunked transfer behavior, your lever is [`WithBufferedContent`](./configure-content.md#buffering-request-content). Buffered content *usually* produces a `Content-Length` header, while unbuffered or unknown-length content lets the runtime fall back to chunked transfer for HTTP/1.1. Nevertheless, FluentHttpClient itself **never sets these headers explicitly**. + +::: + ## Authentication Headers For authentication, FluentHttpClient provides dedicated extensions that set the `Authorization` header. diff --git a/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs b/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs index 9ded094..472ba8e 100644 --- a/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs +++ b/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs @@ -41,6 +41,20 @@ public void WithHeader_ThrowsArgumentNullException_WhenKeyIsNull() ex.ParamName.ShouldBe("key"); } + [Theory] + [InlineData("Host")] + [InlineData("Content-Length")] + [InlineData("Transfer-Encoding")] + public void WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) + { + var builder = CreateBuilder(); + + var ex = Should.Throw(() => + builder.WithHeader(key, "value")); + + ex.ParamName.ShouldBe("key"); + } + [Fact] public void WithHeader_ThrowsArgumentNullException_WhenValueIsNull() { @@ -95,6 +109,21 @@ public void WithHeader_ThrowsArgumentNullException_WhenKeyIsNull() ex.ParamName.ShouldBe("key"); } + [Theory] + [InlineData("Host")] + [InlineData("Content-Length")] + [InlineData("Transfer-Encoding")] + public void WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) + { + var builder = CreateBuilder(); + var values = new[] { "one", "two" }; + + var ex = Should.Throw(() => + builder.WithHeader(key, values)); + + ex.ParamName.ShouldBe("key"); + } + [Fact] public void WithHeader_ThrowsArgumentNullException_WhenValuesIsNull() { @@ -175,8 +204,27 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull() var ex = Should.Throw(() => configurator(message.Headers)); - ex.ParamName.ShouldBe("headers"); - ex.Message.ShouldStartWith("Header name cannot be null."); + ex.ParamName.ShouldBe("key"); + } + + [Theory] + [InlineData("Host")] + [InlineData("Content-Length")] + [InlineData("Transfer-Encoding")] + public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) + { + var builder = CreateBuilder(); + var headers = new[] + { + new KeyValuePair(key, "1") + }; + + builder.WithHeaders(headers); + + var ex = await Should.ThrowAsync(async () => + await builder.BuildRequest(HttpMethod.Get, CancellationToken.None)); + + ex.ParamName.ShouldBe("key"); } [Fact] @@ -195,8 +243,7 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderValueIsNull() var ex = Should.Throw(() => configurator(message.Headers)); - ex.ParamName.ShouldBe("headers"); - ex.Message.ShouldStartWith("Header values for 'X-One' cannot be null."); + ex.ParamName.ShouldBe("value"); } } @@ -252,6 +299,26 @@ public void WithHeaders_ThrowsArgumentNullException_WhenHeadersIsNull() ex.ParamName.ShouldBe("headers"); } + [Theory] + [InlineData("Host")] + [InlineData("Content-Length")] + [InlineData("Transfer-Encoding")] + public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) + { + var builder = CreateBuilder(); + var headers = new[] + { + new KeyValuePair>(key, new[] { "1" }) + }; + + builder.WithHeaders(headers); + + var ex = await Should.ThrowAsync(async () => + await builder.BuildRequest(HttpMethod.Get, CancellationToken.None)); + + ex.ParamName.ShouldBe("key"); + } + [Fact] public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull() { @@ -268,8 +335,7 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull() var ex = Should.Throw(() => configurator(message.Headers)); - ex.ParamName.ShouldBe("headers"); - ex.Message.ShouldStartWith("Header name cannot be null."); + ex.ParamName.ShouldBe("key"); } [Fact] @@ -288,8 +354,7 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderValuesIsNull() var ex = Should.Throw(() => configurator(message.Headers)); - ex.ParamName.ShouldBe("headers"); - ex.Message.ShouldStartWith("Header values for 'X-One' cannot be null."); + ex.ParamName.ShouldBe("values"); } } diff --git a/src/FluentHttpClient/FluentHeaderExtensions.cs b/src/FluentHttpClient/FluentHeaderExtensions.cs index 2012be2..2a0a587 100644 --- a/src/FluentHttpClient/FluentHeaderExtensions.cs +++ b/src/FluentHttpClient/FluentHeaderExtensions.cs @@ -5,6 +5,14 @@ namespace FluentHttpClient; /// public static class FluentHeaderExtensions { + private static readonly HashSet _reservedHeaders = + new(StringComparer.OrdinalIgnoreCase) + { + "Host", + "Content-Length", + "Transfer-Encoding" + }; + /// /// Adds the specified header and its value to the request. /// @@ -16,6 +24,14 @@ public static HttpRequestBuilder WithHeader(this HttpRequestBuilder builder, str Guard.AgainstNull(key, nameof(key)); Guard.AgainstNull(value, nameof(value)); + if (_reservedHeaders.Contains(key)) + { + throw new ArgumentException( + $"Header '{key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " + + "Configure the request URI or content instead.", + nameof(key)); + } + builder.HeaderConfigurators.Add(target => target.TryAddWithoutValidation(key, value)); @@ -33,6 +49,14 @@ public static HttpRequestBuilder WithHeader(this HttpRequestBuilder builder, str Guard.AgainstNull(key, nameof(key)); Guard.AgainstNull(values, nameof(values)); + if (_reservedHeaders.Contains(key)) + { + throw new ArgumentException( + $"Header '{key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " + + "Configure the request URI or content instead.", + nameof(key)); + } + builder.HeaderConfigurators.Add(target => target.TryAddWithoutValidation(key, values)); @@ -52,16 +76,15 @@ public static HttpRequestBuilder WithHeaders(this HttpRequestBuilder builder, IE { foreach (var header in headers) { - if (header.Key is null) - { - throw new ArgumentException("Header name cannot be null.", nameof(headers)); - } + Guard.AgainstNull(header.Key, "key"); + Guard.AgainstNull(header.Value, "value"); - if (header.Value is null) + if (_reservedHeaders.Contains(header.Key)) { throw new ArgumentException( - $"Header values for '{header.Key}' cannot be null.", - nameof(headers)); + $"Header '{header.Key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " + + "Configure the request URI or content instead.", + "key"); } target.TryAddWithoutValidation(header.Key, header.Value); @@ -86,16 +109,15 @@ public static HttpRequestBuilder WithHeaders( { foreach (var header in headers) { - if (header.Key is null) - { - throw new ArgumentException("Header name cannot be null.", nameof(headers)); - } + Guard.AgainstNull(header.Key, "key"); + Guard.AgainstNull(header.Value, "values"); - if (header.Value is null) + if (_reservedHeaders.Contains(header.Key)) { throw new ArgumentException( - $"Header values for '{header.Key}' cannot be null.", - nameof(headers)); + $"Header '{header.Key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " + + "Configure the request URI or content instead.", + "key"); } target.TryAddWithoutValidation(header.Key, header.Value); diff --git a/src/version.json b/src/version.json index 004d664..175e865 100644 --- a/src/version.json +++ b/src/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "5.0.0-rc1", + "version": "5.0.0-rc2", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$"