From b53041aebd963b1ead925a0feac1037e35704ddf Mon Sep 17 00:00:00 2001 From: AlexanderDotH Date: Sun, 28 Dec 2025 01:41:07 +0100 Subject: [PATCH 1/6] fix(DevBase.Net): Ensure explicit Content-Type header is applied to HttpContent (v1.4.4) --- DevBase.Net/Core/RequestHttp.cs | 8 +++++++- DevBase.Net/DevBase.Net.csproj | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/DevBase.Net/Core/RequestHttp.cs b/DevBase.Net/Core/RequestHttp.cs index 7b76621..9b4e385 100644 --- a/DevBase.Net/Core/RequestHttp.cs +++ b/DevBase.Net/Core/RequestHttp.cs @@ -233,7 +233,13 @@ public HttpRequestMessage ToHttpRequestMessage() byte[] bodyArray = this.Body.ToArray(); message.Content = new ByteArrayContent(bodyArray); - if (this._requestBuilder.RequestHeaderBuilder?.GetHeader("Content-Type") == null) + string? explicitContentType = this._requestBuilder.RequestHeaderBuilder?.GetHeader("Content-Type"); + + if (explicitContentType != null) + { + message.Content.Headers.TryAddWithoutValidation("Content-Type", explicitContentType); + } + else { if (this._formBuilder != null) { diff --git a/DevBase.Net/DevBase.Net.csproj b/DevBase.Net/DevBase.Net.csproj index afcf81b..306ac8b 100644 --- a/DevBase.Net/DevBase.Net.csproj +++ b/DevBase.Net/DevBase.Net.csproj @@ -15,7 +15,7 @@ https://github.com/AlexanderDotH/DevBase.git https://github.com/AlexanderDotH/DevBase.git git - 1.4.3 + 1.4.4 MIT false http;client;requests;proxy;socks5;jwt;authentication;fluent-api;async;retry;rate-limiting;json;html-parsing From 4609a355ec778f096fa32286327d3706ac266909 Mon Sep 17 00:00:00 2001 From: AlexanderDotH Date: Sun, 28 Dec 2025 01:48:37 +0100 Subject: [PATCH 2/6] fix(DevBase.Net): Ensure explicit Content-Type header is applied to HttpContent (v1.4.4) --- DevBase.Net/Core/RequestHttp.cs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/DevBase.Net/Core/RequestHttp.cs b/DevBase.Net/Core/RequestHttp.cs index 9b4e385..b36f37e 100644 --- a/DevBase.Net/Core/RequestHttp.cs +++ b/DevBase.Net/Core/RequestHttp.cs @@ -132,8 +132,8 @@ public override async Task SendAsync(CancellationToken cancellationTok metricsBuilder.SetProxy(this._proxy != null, this._proxy?.Key); using HttpRequestMessage httpRequest = this.ToHttpRequestMessage(); - httpRequest.Version = this._httpVersion; - httpRequest.VersionPolicy = this._httpVersionPolicy; + httpRequest.Version = new Version(3, 0); + httpRequest.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; metricsBuilder.MarkConnectStart(); HttpResponseMessage httpResponse = await client.SendAsync(httpRequest, @@ -233,13 +233,7 @@ public HttpRequestMessage ToHttpRequestMessage() byte[] bodyArray = this.Body.ToArray(); message.Content = new ByteArrayContent(bodyArray); - string? explicitContentType = this._requestBuilder.RequestHeaderBuilder?.GetHeader("Content-Type"); - - if (explicitContentType != null) - { - message.Content.Headers.TryAddWithoutValidation("Content-Type", explicitContentType); - } - else + if (this._requestBuilder.RequestHeaderBuilder?.GetHeader("Content-Type") == null) { if (this._formBuilder != null) { @@ -290,8 +284,6 @@ private string BuildClientKey() sb.Append(this._validateCertificates); sb.Append("|redirect:"); sb.Append(this._followRedirects); - sb.Append("|httpver:"); - sb.Append(this._httpVersion); return sb.ToStringAndRelease(); } From da6fb88ce317ad24bc202f4bda37fd53eb8a9800 Mon Sep 17 00:00:00 2001 From: AlexanderDotH Date: Sun, 28 Dec 2025 02:08:44 +0100 Subject: [PATCH 3/6] feat(DevBase.Net): v1.4.0 - Content-Type fix and performance optimizations - Fix: Explicit Content-Type header now correctly applied to HttpContent.Headers - Perf: Replace LINQ with for loops in HttpKeyValueListBuilder (GetEntryValue, AnyEntry, SetEntryValue) - Perf: Cache Uri instead of creating multiple times in SendAsync - Perf: Skip interceptor iteration when empty - Perf: Use StringComparison instead of ToLowerInvariant() in IsProxyError - Perf: Make HandleRateLimitResponse static - Add ContentTypeHeaderTest unit tests --- .../Abstract/HttpKeyValueListBuilder.cs | 34 ++++-- DevBase.Net/Core/RequestHttp.cs | 52 ++++---- DevBase.Net/DevBase.Net.csproj | 2 +- .../DevBaseRequests/ContentTypeHeaderTest.cs | 111 ++++++++++++++++++ .../DevBaseRequests/FileUploadTest.cs | 5 +- 5 files changed, 170 insertions(+), 34 deletions(-) create mode 100644 DevBase.Test/DevBaseRequests/ContentTypeHeaderTest.cs diff --git a/DevBase.Net/Abstract/HttpKeyValueListBuilder.cs b/DevBase.Net/Abstract/HttpKeyValueListBuilder.cs index 2476e9f..0b36c6f 100644 --- a/DevBase.Net/Abstract/HttpKeyValueListBuilder.cs +++ b/DevBase.Net/Abstract/HttpKeyValueListBuilder.cs @@ -78,8 +78,15 @@ protected void RemoveEntryValue(TKeyK value) => /// /// The key. /// The value. - protected TKeyV GetEntryValue(TKeyK key) => - this.Entries.FirstOrDefault(e => KeyEquals(e.Key, key)).Value; + protected TKeyV GetEntryValue(TKeyK key) + { + for (int i = 0; i < this.Entries.Count; i++) + { + if (KeyEquals(this.Entries[i].Key, key)) + return this.Entries[i].Value; + } + return default!; + } /// /// Gets the value at the specified index. @@ -96,13 +103,13 @@ protected TKeyV GetEntryValue(int index) => /// The new value. protected void SetEntryValue(TKeyK key, TKeyV value) { - int index = this.Entries - .FindIndex(e => KeyEquals(e.Key, key)); - - if (index >= 0) + for (int i = 0; i < this.Entries.Count; i++) { - TKeyK existingKey = this.Entries[index].Key; - this.Entries[index] = KeyValuePair.Create(existingKey, value); + if (KeyEquals(this.Entries[i].Key, key)) + { + this.Entries[i] = KeyValuePair.Create(this.Entries[i].Key, value); + return; + } } } @@ -122,8 +129,15 @@ protected void SetEntryValue(int index, TKeyV value) /// /// The key to check. /// True if exists, false otherwise. - protected bool AnyEntry(TKeyK key) => - this.Entries.Exists(e => KeyEquals(e.Key, key)); + protected bool AnyEntry(TKeyK key) + { + for (int i = 0; i < this.Entries.Count; i++) + { + if (KeyEquals(this.Entries[i].Key, key)) + return true; + } + return false; + } private static bool KeyEquals(TKeyK? a, TKeyK? b) { diff --git a/DevBase.Net/Core/RequestHttp.cs b/DevBase.Net/Core/RequestHttp.cs index b36f37e..c3ac6ac 100644 --- a/DevBase.Net/Core/RequestHttp.cs +++ b/DevBase.Net/Core/RequestHttp.cs @@ -28,7 +28,7 @@ public override Request Build() if (this._requestBuilder.RequestHeaderBuilder != null) { - userHeaders = this._requestBuilder.RequestHeaderBuilder.GetEntries().ToList(); + userHeaders = [..this._requestBuilder.RequestHeaderBuilder.GetEntries()]; userDefinedUserAgent = this._requestBuilder.RequestHeaderBuilder.GetPreBuildUserAgent(); } @@ -101,9 +101,12 @@ public override async Task SendAsync(CancellationToken cancellationTok int attemptNumber = 0; System.Exception? lastException = null; - foreach (Interfaces.IRequestInterceptor interceptor in this._requestInterceptors.OrderBy(i => i.Order)) + if (this._requestInterceptors.Count > 0) { - await interceptor.OnRequestAsync(this, token); + foreach (Interfaces.IRequestInterceptor interceptor in this._requestInterceptors) + { + await interceptor.OnRequestAsync(this, token); + } } if (this._hostCheckConfig != null) @@ -122,6 +125,8 @@ public override async Task SendAsync(CancellationToken cancellationTok await Task.Delay(delay, token); } + Uri requestUri = this.GetUri()!; + try { HttpClient client = this.GetOrCreateClient(); @@ -143,7 +148,7 @@ public override async Task SendAsync(CancellationToken cancellationTok if (httpResponse.StatusCode == HttpStatusCode.TooManyRequests) { - RateLimitException rateLimitException = this.HandleRateLimitResponse(httpResponse); + RateLimitException rateLimitException = HandleRateLimitResponse(httpResponse, requestUri); if (attemptNumber > this._retryPolicy.MaxRetries) throw rateLimitException; @@ -166,13 +171,16 @@ public override async Task SendAsync(CancellationToken cancellationTok Response response = new Response(httpResponse, contentStream, metricsBuilder.Build()) { - RequestUri = new Uri(this.Uri.ToString()), + RequestUri = requestUri, FromCache = false }; - foreach (Interfaces.IResponseInterceptor interceptor in this._responseInterceptors.OrderBy(i => i.Order)) + if (this._responseInterceptors.Count > 0) { - await interceptor.OnResponseAsync(response, token); + foreach (Interfaces.IResponseInterceptor interceptor in this._responseInterceptors) + { + await interceptor.OnResponseAsync(response, token); + } } return response; @@ -183,7 +191,7 @@ public override async Task SendAsync(CancellationToken cancellationTok } catch (OperationCanceledException) { - lastException = new RequestTimeoutException(this._timeout, new Uri(this.Uri.ToString()), attemptNumber); + lastException = new RequestTimeoutException(this._timeout, requestUri, attemptNumber); if (attemptNumber > this._retryPolicy.MaxRetries) throw lastException; @@ -198,9 +206,7 @@ public override async Task SendAsync(CancellationToken cancellationTok } catch (HttpRequestException ex) { - string uri = this.Uri.ToString(); - string host = new Uri(uri).Host; - lastException = new NetworkException(ex.Message, ex, host, attemptNumber); + lastException = new NetworkException(ex.Message, ex, requestUri.Host, attemptNumber); if (attemptNumber > this._retryPolicy.MaxRetries) throw lastException; @@ -224,6 +230,9 @@ public HttpRequestMessage ToHttpRequestMessage() { foreach (KeyValuePair entry in this._requestBuilder.RequestHeaderBuilder.GetEntries()) { + if (entry.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) + continue; + message.Headers.TryAddWithoutValidation(entry.Key, entry.Value); } } @@ -233,7 +242,13 @@ public HttpRequestMessage ToHttpRequestMessage() byte[] bodyArray = this.Body.ToArray(); message.Content = new ByteArrayContent(bodyArray); - if (this._requestBuilder.RequestHeaderBuilder?.GetHeader("Content-Type") == null) + string? explicitContentType = this._requestBuilder.RequestHeaderBuilder?.GetHeader("Content-Type"); + + if (explicitContentType != null) + { + message.Content.Headers.TryAddWithoutValidation("Content-Type", explicitContentType); + } + else { if (this._formBuilder != null) { @@ -321,7 +336,7 @@ private SocketsHttpHandler CreateHandler() private async Task CheckHostReachabilityAsync(CancellationToken cancellationToken) { - Uri uri = new Uri(this.Uri.ToString()); + Uri uri = this.GetUri()!; string host = uri.Host; if (this._hostCheckConfig!.Method == EnumHostCheckMethod.Ping) @@ -347,10 +362,9 @@ private async Task CheckHostReachabilityAsync(CancellationToken cancellationToke private static bool IsProxyError(HttpRequestException ex) { - string message = ex.Message.ToLowerInvariant(); - return message.Contains("proxy") || - message.Contains("407") || - ex.StatusCode == HttpStatusCode.ProxyAuthenticationRequired; + return ex.StatusCode == HttpStatusCode.ProxyAuthenticationRequired || + ex.Message.Contains("proxy", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("407", StringComparison.Ordinal); } private void ApplyScrapingBypassIfConfigured(List>? userHeaders, string? userDefinedUserAgent) @@ -385,10 +399,8 @@ private void ApplyScrapingBypassIfConfigured(List>? } } - private RateLimitException HandleRateLimitResponse(HttpResponseMessage response) + private static RateLimitException HandleRateLimitResponse(HttpResponseMessage response, Uri requestUri) { - Uri requestUri = new Uri(this.Uri.ToString()); - if (response.Headers.TryGetValues("Retry-After", out IEnumerable? retryAfterValues)) { string? retryAfter = retryAfterValues.FirstOrDefault(); diff --git a/DevBase.Net/DevBase.Net.csproj b/DevBase.Net/DevBase.Net.csproj index 306ac8b..5d8a7e1 100644 --- a/DevBase.Net/DevBase.Net.csproj +++ b/DevBase.Net/DevBase.Net.csproj @@ -15,7 +15,7 @@ https://github.com/AlexanderDotH/DevBase.git https://github.com/AlexanderDotH/DevBase.git git - 1.4.4 + 1.4.0 MIT false http;client;requests;proxy;socks5;jwt;authentication;fluent-api;async;retry;rate-limiting;json;html-parsing diff --git a/DevBase.Test/DevBaseRequests/ContentTypeHeaderTest.cs b/DevBase.Test/DevBaseRequests/ContentTypeHeaderTest.cs new file mode 100644 index 0000000..b13686d --- /dev/null +++ b/DevBase.Test/DevBaseRequests/ContentTypeHeaderTest.cs @@ -0,0 +1,111 @@ +using System.Net.Http.Headers; +using DevBase.Net.Core; + +namespace DevBase.Test.DevBaseRequests; + +[TestFixture] +public class ContentTypeHeaderTest +{ + [Test] + public void ExplicitContentType_ShouldBeAppliedToContentHeaders() + { + Request request = new Request("https://example.com/api") + .WithHeader("Content-Type", "application/json") + .WithJsonBody("{\"test\": \"value\"}"); + + using HttpRequestMessage httpMessage = request.ToHttpRequestMessage(); + + Assert.That(httpMessage.Content, Is.Not.Null); + Assert.That(httpMessage.Content!.Headers.ContentType?.MediaType, Is.EqualTo("application/json")); + } + + [Test] + public void ExplicitContentType_ShouldNotBeInRequestHeaders() + { + Request request = new Request("https://example.com/api") + .WithHeader("Content-Type", "application/json") + .WithJsonBody("{\"test\": \"value\"}"); + + using HttpRequestMessage httpMessage = request.ToHttpRequestMessage(); + + bool hasContentTypeInRequestHeaders = httpMessage.Headers + .Any(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)); + Assert.That(hasContentTypeInRequestHeaders, Is.False, "Content-Type should not be in request headers"); + } + + [Test] + public void CustomContentType_ShouldBePreserved() + { + const string customContentType = "application/vnd.api+json"; + + Request request = new Request("https://example.com/api") + .WithHeader("Content-Type", customContentType) + .WithJsonBody("{\"data\": {}}"); + + using HttpRequestMessage httpMessage = request.ToHttpRequestMessage(); + + Assert.That(httpMessage.Content, Is.Not.Null); + Assert.That(httpMessage.Content!.Headers.ContentType?.MediaType, Is.EqualTo(customContentType)); + } + + [Test] + public void NoExplicitContentType_ShouldDefaultToJsonForJsonBody() + { + Request request = new Request("https://example.com/api") + .WithJsonBody("{\"test\": \"value\"}"); + + using HttpRequestMessage httpMessage = request.ToHttpRequestMessage(); + + Assert.That(httpMessage.Content, Is.Not.Null); + Assert.That(httpMessage.Content!.Headers.ContentType?.MediaType, Is.EqualTo("application/json")); + } + + [Test] + public void ContentTypeWithCharset_ShouldBePreserved() + { + const string contentTypeWithCharset = "application/json; charset=utf-8"; + + Request request = new Request("https://example.com/api") + .WithHeader("Content-Type", contentTypeWithCharset) + .WithJsonBody("{\"test\": \"value\"}"); + + using HttpRequestMessage httpMessage = request.ToHttpRequestMessage(); + + Assert.That(httpMessage.Content, Is.Not.Null); + string? fullContentType = httpMessage.Content!.Headers.ContentType?.ToString(); + Assert.That(fullContentType, Is.EqualTo(contentTypeWithCharset)); + } + + [Test] + public void OtherHeaders_ShouldStillBeInRequestHeaders() + { + Request request = new Request("https://example.com/api") + .WithHeader("Content-Type", "application/json") + .WithHeader("X-Custom-Header", "custom-value") + .WithHeader("Authorization", "Bearer token123") + .WithJsonBody("{\"test\": \"value\"}"); + + using HttpRequestMessage httpMessage = request.ToHttpRequestMessage(); + + Assert.That(httpMessage.Headers.Contains("X-Custom-Header"), Is.True); + Assert.That(httpMessage.Headers.Contains("Authorization"), Is.True); + Assert.That(httpMessage.Headers.GetValues("X-Custom-Header").First(), Is.EqualTo("custom-value")); + } + + [Test] + public void ContentTypeCaseInsensitive_ShouldBeHandledCorrectly() + { + Request request = new Request("https://example.com/api") + .WithHeader("content-type", "application/json") + .WithJsonBody("{\"test\": \"value\"}"); + + using HttpRequestMessage httpMessage = request.ToHttpRequestMessage(); + + bool hasContentTypeInRequestHeaders = httpMessage.Headers + .Any(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)); + Assert.That(hasContentTypeInRequestHeaders, Is.False, "Content-Type (any case) should not be in request headers"); + + Assert.That(httpMessage.Content, Is.Not.Null); + Assert.That(httpMessage.Content!.Headers.ContentType?.MediaType, Is.EqualTo("application/json")); + } +} diff --git a/DevBase.Test/DevBaseRequests/FileUploadTest.cs b/DevBase.Test/DevBaseRequests/FileUploadTest.cs index 0d5fe34..f063b1a 100644 --- a/DevBase.Test/DevBaseRequests/FileUploadTest.cs +++ b/DevBase.Test/DevBaseRequests/FileUploadTest.cs @@ -283,9 +283,8 @@ public void Request_WithCustomContentType_DoesNotOverwrite() request.Build(); var httpMessage = request.ToHttpRequestMessage(); - // Custom Content-Type is in request headers, not message headers - // The Content-Type should not be overwritten by multipart detection - Assert.That(httpMessage.Content!.Headers.ContentType, Is.Null); + // Custom Content-Type should be preserved in content headers, not overwritten by multipart detection + Assert.That(httpMessage.Content!.Headers.ContentType?.MediaType, Is.EqualTo("custom/type")); } #endregion From 9fd893f7596a118aeb40d4cae3340b54632dfeb5 Mon Sep 17 00:00:00 2001 From: AlexanderDotH Date: Sun, 28 Dec 2025 02:10:20 +0100 Subject: [PATCH 4/6] chore(DevBase.Net): bump version to 1.4.4 --- DevBase.Net/DevBase.Net.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevBase.Net/DevBase.Net.csproj b/DevBase.Net/DevBase.Net.csproj index 5d8a7e1..306ac8b 100644 --- a/DevBase.Net/DevBase.Net.csproj +++ b/DevBase.Net/DevBase.Net.csproj @@ -15,7 +15,7 @@ https://github.com/AlexanderDotH/DevBase.git https://github.com/AlexanderDotH/DevBase.git git - 1.4.0 + 1.4.4 MIT false http;client;requests;proxy;socks5;jwt;authentication;fluent-api;async;retry;rate-limiting;json;html-parsing From 2e90f6f2b29aa1400018faae4aae80d89db90fa3 Mon Sep 17 00:00:00 2001 From: AlexanderDotH Date: Sun, 28 Dec 2025 02:19:29 +0100 Subject: [PATCH 5/6] chore(DevBase.Net): bump version to 1.4.5 --- DevBase.Net/DevBase.Net.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevBase.Net/DevBase.Net.csproj b/DevBase.Net/DevBase.Net.csproj index 306ac8b..16a1428 100644 --- a/DevBase.Net/DevBase.Net.csproj +++ b/DevBase.Net/DevBase.Net.csproj @@ -15,7 +15,7 @@ https://github.com/AlexanderDotH/DevBase.git https://github.com/AlexanderDotH/DevBase.git git - 1.4.4 + 1.4.5 MIT false http;client;requests;proxy;socks5;jwt;authentication;fluent-api;async;retry;rate-limiting;json;html-parsing From fd67f92e53f93f332da93bcc86d690e80cf20788 Mon Sep 17 00:00:00 2001 From: AlexanderDotH Date: Sun, 28 Dec 2025 02:30:14 +0100 Subject: [PATCH 6/6] fix(DevBase.Net): Use configured HTTP version instead of hardcoded HTTP/3 (v1.4.6) --- DevBase.Net/Core/RequestHttp.cs | 4 ++-- DevBase.Net/DevBase.Net.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DevBase.Net/Core/RequestHttp.cs b/DevBase.Net/Core/RequestHttp.cs index c3ac6ac..1af60ca 100644 --- a/DevBase.Net/Core/RequestHttp.cs +++ b/DevBase.Net/Core/RequestHttp.cs @@ -137,8 +137,8 @@ public override async Task SendAsync(CancellationToken cancellationTok metricsBuilder.SetProxy(this._proxy != null, this._proxy?.Key); using HttpRequestMessage httpRequest = this.ToHttpRequestMessage(); - httpRequest.Version = new Version(3, 0); - httpRequest.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + httpRequest.Version = this._httpVersion; + httpRequest.VersionPolicy = this._httpVersionPolicy; metricsBuilder.MarkConnectStart(); HttpResponseMessage httpResponse = await client.SendAsync(httpRequest, diff --git a/DevBase.Net/DevBase.Net.csproj b/DevBase.Net/DevBase.Net.csproj index 16a1428..c7460b0 100644 --- a/DevBase.Net/DevBase.Net.csproj +++ b/DevBase.Net/DevBase.Net.csproj @@ -15,7 +15,7 @@ https://github.com/AlexanderDotH/DevBase.git https://github.com/AlexanderDotH/DevBase.git git - 1.4.5 + 1.4.6 MIT false http;client;requests;proxy;socks5;jwt;authentication;fluent-api;async;retry;rate-limiting;json;html-parsing