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 7b76621..1af60ca 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) { @@ -284,8 +299,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(); } @@ -323,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) @@ -349,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) @@ -387,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 afcf81b..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.3 + 1.4.6 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