Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions DevBase.Net/Abstract/HttpKeyValueListBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,15 @@ protected void RemoveEntryValue(TKeyK value) =>
/// </summary>
/// <param name="key">The key.</param>
/// <returns>The value.</returns>
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!;
}

/// <summary>
/// Gets the value at the specified index.
Expand All @@ -96,13 +103,13 @@ protected TKeyV GetEntryValue(int index) =>
/// <param name="value">The new value.</param>
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;
}
}
}

Expand All @@ -122,8 +129,15 @@ protected void SetEntryValue(int index, TKeyV value)
/// </summary>
/// <param name="key">The key to check.</param>
/// <returns>True if exists, false otherwise.</returns>
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)
{
Expand Down
54 changes: 32 additions & 22 deletions DevBase.Net/Core/RequestHttp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -101,9 +101,12 @@ public override async Task<Response> 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)
Expand All @@ -122,6 +125,8 @@ public override async Task<Response> SendAsync(CancellationToken cancellationTok
await Task.Delay(delay, token);
}

Uri requestUri = this.GetUri()!;

try
{
HttpClient client = this.GetOrCreateClient();
Expand All @@ -143,7 +148,7 @@ public override async Task<Response> 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;
Expand All @@ -166,13 +171,16 @@ public override async Task<Response> 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;
Expand All @@ -183,7 +191,7 @@ public override async Task<Response> 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;
Expand All @@ -198,9 +206,7 @@ public override async Task<Response> 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;
Expand All @@ -224,6 +230,9 @@ public HttpRequestMessage ToHttpRequestMessage()
{
foreach (KeyValuePair<string, string> entry in this._requestBuilder.RequestHeaderBuilder.GetEntries())
{
if (entry.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))
continue;

message.Headers.TryAddWithoutValidation(entry.Key, entry.Value);
}
}
Expand All @@ -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)
{
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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)
Expand All @@ -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<KeyValuePair<string, string>>? userHeaders, string? userDefinedUserAgent)
Expand Down Expand Up @@ -387,10 +399,8 @@ private void ApplyScrapingBypassIfConfigured(List<KeyValuePair<string, string>>?
}
}

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<string>? retryAfterValues))
{
string? retryAfter = retryAfterValues.FirstOrDefault();
Expand Down
2 changes: 1 addition & 1 deletion DevBase.Net/DevBase.Net.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<PackageProjectUrl>https://github.com/AlexanderDotH/DevBase.git</PackageProjectUrl>
<RepositoryUrl>https://github.com/AlexanderDotH/DevBase.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<Version>1.4.3</Version>
<Version>1.4.6</Version>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageTags>http;client;requests;proxy;socks5;jwt;authentication;fluent-api;async;retry;rate-limiting;json;html-parsing</PackageTags>
Expand Down
111 changes: 111 additions & 0 deletions DevBase.Test/DevBaseRequests/ContentTypeHeaderTest.cs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
5 changes: 2 additions & 3 deletions DevBase.Test/DevBaseRequests/FileUploadTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading