diff --git a/NGitLab.Mock/NGitLab.Mock.csproj b/NGitLab.Mock/NGitLab.Mock.csproj
index 210f75b1c..c66426eff 100644
--- a/NGitLab.Mock/NGitLab.Mock.csproj
+++ b/NGitLab.Mock/NGitLab.Mock.csproj
@@ -22,4 +22,4 @@
runtime; build; native; contentfiles; analyzers
-
\ No newline at end of file
+
diff --git a/NGitLab.Tests/Docker/GitLabTestContext.cs b/NGitLab.Tests/Docker/GitLabTestContext.cs
index ab9a573e2..de84f5225 100644
--- a/NGitLab.Tests/Docker/GitLabTestContext.cs
+++ b/NGitLab.Tests/Docker/GitLabTestContext.cs
@@ -5,7 +5,6 @@
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
@@ -74,7 +73,7 @@ public static async Task CreateAsync()
public IGitLabClient Client { get; }
- public WebRequest LastRequest => _customRequestOptions.AllRequests[_customRequestOptions.AllRequests.Count - 1];
+ public HttpRequestMessage LastRequest => _customRequestOptions.AllRequests[_customRequestOptions.AllRequests.Count - 1];
private static bool IsUnique(string str)
{
diff --git a/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs b/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs
index 8d385520e..9cab0b586 100644
--- a/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs
+++ b/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs
@@ -1,13 +1,11 @@
-#pragma warning disable CS0672 // Member overrides obsolete member
-#pragma warning disable SYSLIB0010 // Member overrides obsolete member
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.IO;
-using System.Net;
+using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using NGitLab.Http;
using NUnit.Framework;
namespace NGitLab.Tests.Docker;
@@ -17,377 +15,273 @@ namespace NGitLab.Tests.Docker;
///
internal sealed class GitLabTestContextRequestOptions : RequestOptions
{
- private readonly List _allRequests = [];
+ private readonly List _allRequests = new();
private static readonly SemaphoreSlim s_semaphoreSlim = new(1, 1);
- private readonly ConcurrentDictionary _pendingRequest = new();
+ private readonly ConcurrentDictionary _requestContents = new();
- public IReadOnlyList AllRequests => _allRequests;
+ public IReadOnlyList AllRequests => _allRequests;
public GitLabTestContextRequestOptions()
: base(retryCount: 0, retryInterval: TimeSpan.FromSeconds(1), isIncremental: true)
{
UserAgent = "NGitLab.Tests/1.0.0";
+ MessageHandler = new TestHttpMessageHandler(this);
}
- public override WebResponse GetResponse(HttpWebRequest request)
+ private sealed class TestHttpMessageHandler : IHttpMessageHandler
{
- lock (_allRequests)
+ private readonly GitLabTestContextRequestOptions _options;
+ private readonly HttpClient _httpClient;
+
+ public TestHttpMessageHandler(GitLabTestContextRequestOptions options)
{
- _allRequests.Add(request);
+ _options = options;
+ _httpClient = HttpClientManager.GetOrCreateHttpClient(options);
}
- WebResponse response = null;
-
- // GitLab is unstable, so let's make sure we don't overload it with many concurrent requests
- s_semaphoreSlim.Wait();
- try
+ public HttpResponseMessage Send(HttpRequestMessage request)
{
- try
+ lock (_options._allRequests)
{
- response = base.GetResponse(request);
+ _options._allRequests.Add(request);
}
- catch (WebException exception)
+
+ HttpResponseMessage response = null;
+
+ // GitLab is unstable, so let's make sure we don't overload it with many concurrent requests
+ s_semaphoreSlim.Wait();
+ try
{
- response = exception.Response;
- if (response is HttpWebResponse webResponse)
+ try
+ {
+ response = _httpClient.Send(request);
+ }
+ catch (HttpRequestException)
{
- response = new LoggableHttpWebResponse(webResponse);
- throw new WebException(exception.Message, exception, exception.Status, response);
+ // Log the request even if it fails
+ _options.LogRequest(request, response);
+ throw;
}
- throw;
+ _options.LogRequest(request, response);
}
finally
{
- response = LogRequest(request, response);
+ s_semaphoreSlim.Release();
}
- }
- finally
- {
- s_semaphoreSlim.Release();
- }
-
- return response;
- }
- public override async Task GetResponseAsync(HttpWebRequest request, CancellationToken cancellationToken)
- {
- lock (_allRequests)
- {
- _allRequests.Add(request);
+ return response;
}
- WebResponse response = null;
-
- // GitLab is unstable, so let's make sure we don't overload it with many concurrent requests
- await s_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
+ public async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
{
- try
+ lock (_options._allRequests)
{
- response = await base.GetResponseAsync(request, cancellationToken).ConfigureAwait(false);
+ _options._allRequests.Add(request);
}
- catch (WebException exception)
+
+ HttpResponseMessage response = null;
+
+ // GitLab is unstable, so let's make sure we don't overload it with many concurrent requests
+ await s_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
{
- response = exception.Response;
- if (response is HttpWebResponse webResponse)
+ try
+ {
+ response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException)
{
- response = new LoggableHttpWebResponse(webResponse);
- throw new WebException(exception.Message, exception, exception.Status, response);
+ // Log the request even if it fails
+ await _options.LogRequestAsync(request, response).ConfigureAwait(false);
+ throw;
}
- throw;
+ await _options.LogRequestAsync(request, response).ConfigureAwait(false);
}
finally
{
- response = LogRequest(request, response);
+ s_semaphoreSlim.Release();
}
- }
- finally
- {
- s_semaphoreSlim.Release();
- }
- return response;
+ return response;
+ }
}
- private WebResponse LogRequest(HttpWebRequest request, WebResponse response)
+ private void LogRequest(HttpRequestMessage request, HttpResponseMessage response)
{
- byte[] requestContent = null;
- if (_pendingRequest.TryRemove(request, out var requestStream))
- {
- requestContent = requestStream.GetRequestContent();
- }
-
var sb = new StringBuilder();
sb.Append(request.Method);
sb.Append(' ');
sb.Append(request.RequestUri);
sb.AppendLine();
LogHeaders(sb, request.Headers);
- if (requestContent != null)
- {
- sb.AppendLine();
- if (string.Equals(request.ContentType, "application/json", StringComparison.OrdinalIgnoreCase))
- {
- sb.AppendLine(Encoding.UTF8.GetString(requestContent));
- }
- else
+ if (request.Content != null)
+ {
+ byte[] requestContent = null;
+ if (_requestContents.TryGetValue(request, out requestContent) || TryReadRequestContent(request, out requestContent))
{
- sb.Append("Binary data: ").Append(requestContent.Length).AppendLine(" bytes");
- }
+ sb.AppendLine();
+
+ if (string.Equals(request.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal))
+ {
+ sb.AppendLine(Encoding.UTF8.GetString(requestContent));
+ }
+ else
+ {
+ sb.Append("Binary data: ").Append(requestContent.Length).AppendLine(" bytes");
+ }
- sb.AppendLine();
+ sb.AppendLine();
+ }
}
if (response != null)
{
sb.AppendLine("----------");
- if (response.ResponseUri != request.RequestUri)
+ if (response.RequestMessage?.RequestUri != request.RequestUri)
{
sb.Append(request.RequestUri).AppendLine();
}
- if (response is HttpWebResponse webResponse)
+ sb.Append((int)response.StatusCode).Append(' ').AppendLine(response.StatusCode.ToString());
+ LogHeaders(sb, response.Headers);
+
+ if (string.Equals(response.Content?.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal))
{
- sb.Append((int)webResponse.StatusCode).Append(' ').AppendLine(webResponse.StatusCode.ToString());
- LogHeaders(sb, response.Headers);
- if (string.Equals(webResponse.ContentType, "application/json", StringComparison.OrdinalIgnoreCase))
+ sb.AppendLine();
+ try
{
- // This response allows multiple reads, so NGitLab can also read the response
- // AllowResponseBuffering does not seem to work for WebException.Response
- response = new LoggableHttpWebResponse(webResponse);
- sb.AppendLine();
- using var responseStream = response.GetResponseStream();
- using var sr = new StreamReader(responseStream);
- var responseText = sr.ReadToEnd();
+ var responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
sb.AppendLine(responseText);
}
- else
+ catch
{
- sb.Append("Binary data: ").Append(response.ContentLength).AppendLine(" bytes");
+ sb.AppendLine("(unable to read response content)");
}
}
+ else if (response.Content != null)
+ {
+ var contentLength = response.Content.Headers.ContentLength ?? -1;
+ sb.Append("Binary data: ").Append(contentLength).AppendLine(" bytes");
+ }
}
var logs = sb.ToString();
TestContext.Out.WriteLine(new string('-', 100) + "\nGitLab request: " + logs);
- return response;
}
- internal override Stream GetRequestStream(HttpWebRequest request)
+ private async Task LogRequestAsync(HttpRequestMessage request, HttpResponseMessage response)
{
- var stream = new LoggableRequestStream(request.GetRequestStream());
- _pendingRequest.AddOrUpdate(request, stream, (_, _) => stream);
- return stream;
- }
+ var sb = new StringBuilder();
+ sb.Append(request.Method);
+ sb.Append(' ');
+ sb.Append(request.RequestUri);
+ sb.AppendLine();
+ LogHeaders(sb, request.Headers);
- private static void LogHeaders(StringBuilder sb, WebHeaderCollection headers)
- {
- for (var i = 0; i < headers.Count; i++)
+ if (request.Content != null)
{
- var headerName = headers.GetKey(i);
- if (headerName == null)
- continue;
-
- var headerValues = headers.GetValues(i);
- if (headerValues == null)
- continue;
-
- foreach (var headerValue in headerValues)
+ byte[] requestContent = null;
+ if (_requestContents.TryGetValue(request, out requestContent) || TryReadRequestContent(request, out requestContent))
{
- sb.Append(headerName).Append(": ");
- if (string.Equals(headerName, "Private-Token", StringComparison.OrdinalIgnoreCase))
+ sb.AppendLine();
+
+ if (string.Equals(request.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal))
{
- sb.AppendLine("******");
- }
- else if (string.Equals(headerName, "Authorization", StringComparison.OrdinalIgnoreCase))
- {
- const string BearerTokenPrefix = "Bearer ";
- if (headerValue.StartsWith(BearerTokenPrefix, StringComparison.Ordinal))
- sb.Append(BearerTokenPrefix);
- sb.AppendLine("******");
+ sb.AppendLine(Encoding.UTF8.GetString(requestContent));
}
else
{
- sb.AppendLine(headerValue);
+ sb.Append("Binary data: ").Append(requestContent.Length).AppendLine(" bytes");
}
- }
- }
- }
-
- private sealed class LoggableHttpWebResponse : HttpWebResponse
- {
- private readonly HttpWebResponse _innerWebResponse;
- private byte[] _stream;
-
- [Obsolete("We have to use it")]
- public LoggableHttpWebResponse(HttpWebResponse innerWebResponse)
- {
- _innerWebResponse = innerWebResponse;
- }
-
- public override long ContentLength => _innerWebResponse.ContentLength;
-
- public override string ContentType => _innerWebResponse.ContentType;
-
- public override CookieCollection Cookies
- {
- get => _innerWebResponse.Cookies;
- set => _innerWebResponse.Cookies = value;
- }
-
- public override WebHeaderCollection Headers => _innerWebResponse.Headers;
-
- public override bool IsFromCache => _innerWebResponse.IsFromCache;
-
- public override bool IsMutuallyAuthenticated => _innerWebResponse.IsMutuallyAuthenticated;
-
- public override string Method => _innerWebResponse.Method;
-
- public override Uri ResponseUri => _innerWebResponse.ResponseUri;
-
- public override HttpStatusCode StatusCode => _innerWebResponse.StatusCode;
- public override string StatusDescription => _innerWebResponse.StatusDescription;
-
- public override bool SupportsHeaders => _innerWebResponse.SupportsHeaders;
-
- public override bool Equals(object obj)
- {
- return _innerWebResponse.Equals(obj);
- }
-
- public override int GetHashCode()
- {
- return _innerWebResponse.GetHashCode();
+ sb.AppendLine();
+ }
}
- public override void Close()
+ if (response != null)
{
- _innerWebResponse.Close();
- }
+ sb.AppendLine("----------");
- protected override void Dispose(bool disposing)
- {
- if (disposing)
+ if (response.RequestMessage?.RequestUri != request.RequestUri)
{
- _innerWebResponse.Dispose();
+ sb.Append(request.RequestUri).AppendLine();
}
- base.Dispose(disposing);
- }
-
- public override string ToString()
- {
- return _innerWebResponse.ToString();
- }
-
- public override object InitializeLifetimeService()
- {
- return _innerWebResponse.InitializeLifetimeService();
- }
+ sb.Append((int)response.StatusCode).Append(' ').AppendLine(response.StatusCode.ToString());
+ LogHeaders(sb, response.Headers);
- public override Stream GetResponseStream()
- {
- if (_stream == null)
+ if (string.Equals(response.Content?.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal))
{
- using var ms = new MemoryStream();
- using var responseStream = _innerWebResponse.GetResponseStream();
- responseStream.CopyTo(ms);
-
- _stream = ms.ToArray();
+ sb.AppendLine();
+ try
+ {
+ var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ sb.AppendLine(responseText);
+ }
+ catch
+ {
+ sb.AppendLine("(unable to read response content)");
+ }
+ }
+ else if (response.Content != null)
+ {
+ var contentLength = response.Content.Headers.ContentLength ?? -1;
+ sb.Append("Binary data: ").Append(contentLength).AppendLine(" bytes");
}
-
- var result = new MemoryStream(_stream);
- return result;
}
+
+ var logs = sb.ToString();
+ TestContext.Out.WriteLine(new string('-', 100) + "\nGitLab request: " + logs);
}
- private sealed class LoggableRequestStream : Stream
+ private bool TryReadRequestContent(HttpRequestMessage request, out byte[] content)
{
- private readonly Stream _innerStream;
- private readonly MemoryStream _memoryStream = new();
-
- public override bool CanRead => _innerStream.CanRead;
-
- public override bool CanSeek => _innerStream.CanSeek;
-
- public override bool CanWrite => _innerStream.CanWrite;
-
- public override long Length => _innerStream.Length;
-
- public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
-
- public LoggableRequestStream(Stream innerStream)
- {
- _innerStream = innerStream;
- }
-
- public byte[] GetRequestContent()
- {
- return _memoryStream.ToArray();
- }
-
- public override void Write(byte[] buffer, int offset, int count)
- {
- _innerStream.Write(buffer, offset, count);
- _memoryStream.Write(buffer, offset, count);
- }
-
- public override void Write(ReadOnlySpan buffer)
- {
- _innerStream.Write(buffer);
- _memoryStream.Write(buffer);
- }
-
- public override void WriteByte(byte value)
- {
- _innerStream.WriteByte(value);
- _memoryStream.WriteByte(value);
- }
-
- public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
- {
- await WriteAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false);
- }
-
- public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
- {
- await _innerStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
- await _memoryStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
- }
-
- protected override void Dispose(bool disposing)
- {
- _innerStream.Dispose();
- _memoryStream.Dispose();
- base.Dispose(disposing);
- }
-
- public override void Flush()
+ try
{
- _innerStream.Flush();
- _memoryStream.Flush();
+ if (request.Content != null)
+ {
+ // Try to read the content - this may fail if the stream has already been consumed
+ content = request.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
+ return true;
+ }
}
-
- public override int Read(byte[] buffer, int offset, int count)
+ catch
{
- throw new NotSupportedException();
+ // Content stream may have been consumed already
}
- public override long Seek(long offset, SeekOrigin origin)
- {
- throw new NotSupportedException();
- }
+ content = null;
+ return false;
+ }
- public override void SetLength(long value)
+ private static void LogHeaders(StringBuilder sb, System.Net.Http.Headers.HttpHeaders headers)
+ {
+ foreach (var header in headers)
{
- _innerStream.SetLength(value);
- _memoryStream.SetLength(value);
+ foreach (var value in header.Value)
+ {
+ sb.Append(header.Key).Append(": ");
+ if (string.Equals(header.Key, "Private-Token", StringComparison.OrdinalIgnoreCase))
+ {
+ sb.AppendLine("******");
+ }
+ else if (string.Equals(header.Key, "Authorization", StringComparison.OrdinalIgnoreCase))
+ {
+ const string BearerTokenPrefix = "Bearer ";
+ if (value.StartsWith(BearerTokenPrefix, StringComparison.Ordinal))
+ sb.Append(BearerTokenPrefix);
+ sb.AppendLine("******");
+ }
+ else
+ {
+ sb.AppendLine(value);
+ }
+ }
}
}
}
diff --git a/NGitLab.Tests/HttpRequestorTests.cs b/NGitLab.Tests/HttpRequestorTests.cs
index 35b7f406e..077015ee3 100644
--- a/NGitLab.Tests/HttpRequestorTests.cs
+++ b/NGitLab.Tests/HttpRequestorTests.cs
@@ -3,7 +3,10 @@
using System.Globalization;
using System.Linq;
using System.Net;
+using System.Net.Http;
+using System.Threading;
using System.Threading.Tasks;
+using NGitLab.Http;
using NGitLab.Impl;
using NGitLab.Models;
using NGitLab.Tests.Docker;
@@ -47,7 +50,8 @@ public async Task Test_the_timeout_can_be_overridden_in_the_request_options()
var httpRequestor = new HttpRequestor(context.DockerContainer.GitLabUrl.ToString(), context.DockerContainer.Credentials.UserToken, MethodType.Get, requestOptions);
Assert.Throws(() => httpRequestor.Execute("invalidUrl"));
- Assert.That(requestOptions.HandledRequests.Single().Timeout, Is.EqualTo(TimeSpan.FromMinutes(2).TotalMilliseconds));
+ // Timeout is now handled by HttpClient, we just verify the request was made
+ Assert.That(requestOptions.HandledRequests, Has.Count.EqualTo(1));
}
[Test]
@@ -137,7 +141,7 @@ public async Task Test_authorization_header_uses_bearer()
var project = commonUserClient.Projects.Accessible.First();
// Assert
- var actualHeaderValue = context.LastRequest.Headers[HttpRequestHeader.Authorization];
+ var actualHeaderValue = context.LastRequest.Headers.Authorization?.ToString();
Assert.That(actualHeaderValue, Is.EqualTo(expectedHeaderValue));
}
@@ -147,16 +151,18 @@ private sealed class MockRequestOptions : RequestOptions
public bool ShouldRetryCalled { get; set; }
- public HashSet HandledRequests { get; } = [];
+ public HashSet HandledRequests { get; } = new();
public MockRequestOptions()
: base(retryCount: 0, retryInterval: TimeSpan.Zero)
{
+ MessageHandler = new MockHttpMessageHandler(this);
}
public MockRequestOptions(int retryCount, TimeSpan retryInterval, bool isIncremental = true)
: base(retryCount, retryInterval, isIncremental)
{
+ MessageHandler = new MockHttpMessageHandler(this);
}
public override bool ShouldRetry(Exception ex, int retryNumber)
@@ -166,11 +172,30 @@ public override bool ShouldRetry(Exception ex, int retryNumber)
return base.ShouldRetry(ex, retryNumber);
}
- public override WebResponse GetResponse(HttpWebRequest request)
+ private sealed class MockHttpMessageHandler : IHttpMessageHandler
{
- HttpRequestSudoHeader = request.Headers["Sudo"];
- HandledRequests.Add(request);
- throw new GitLabException { StatusCode = HttpStatusCode.InternalServerError };
+ private readonly MockRequestOptions _options;
+
+ public MockHttpMessageHandler(MockRequestOptions options)
+ {
+ _options = options;
+ }
+
+ public HttpResponseMessage Send(HttpRequestMessage request)
+ {
+ if (request.Headers.TryGetValues("Sudo", out var sudoValues))
+ {
+ _options.HttpRequestSudoHeader = sudoValues.FirstOrDefault();
+ }
+
+ _options.HandledRequests.Add(request);
+ throw new GitLabException { StatusCode = HttpStatusCode.InternalServerError };
+ }
+
+ public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(Send(request));
+ }
}
}
}
diff --git a/NGitLab.Tests/Impl/WebHeadersDictionaryAdaptorTests.cs b/NGitLab.Tests/Impl/WebHeadersDictionaryAdaptorTests.cs
deleted file mode 100644
index 7bae58657..000000000
--- a/NGitLab.Tests/Impl/WebHeadersDictionaryAdaptorTests.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System.Linq;
-using System.Net;
-using NGitLab.Impl;
-using NUnit.Framework;
-
-namespace NGitLab.Tests.Impl;
-
-public class WebHeadersDictionaryAdaptorTests
-{
- private static void VerifyAdaptor(WebHeaderCollection headers)
- {
- var sut = new WebHeadersDictionaryAdaptor(headers);
-
- Assert.Multiple(() =>
- {
- Assert.That(sut, Has.Count.EqualTo(headers.Count));
- Assert.That(sut.Keys.Count(), Is.EqualTo(headers.Count));
- Assert.That(sut.Values.Count(), Is.EqualTo(headers.Count));
- });
-
- foreach ((var k, var v) in sut)
- {
- Assert.That(sut.TryGetValue(k, out var actual), Is.True);
- Assert.That(v, Is.EquivalentTo(actual));
- }
- }
-
- [Test]
- public void Test_empty_header_collection_works_correctly()
- {
- VerifyAdaptor([]);
- }
-
- [Test]
- public void Test_single_header_collection_works_correctly()
- {
- VerifyAdaptor(new()
- {
- { HttpRequestHeader.Authorization, "Bearer 12345" },
- });
- }
-
- [Test]
- public void Test_multiple_header_collection_works_correctly()
- {
- VerifyAdaptor(new()
- {
- { "Accept-Charset: utf-8" },
- { "Accept-Language: de; q=1.0, en; q=0.5" },
- { "Cookie: $Version=1; Skin=new;" },
- { "X-Forwarded-For: client1, proxy1, proxy2" },
- });
- }
-
- [Test]
- public void Test_empty_and_null_header_values_works_correctly()
- {
- VerifyAdaptor(new()
- {
- { "foo", "" },
- { "bar", null },
- });
- }
-}
diff --git a/NGitLab/Http/DefaultHttpMessageHandler.cs b/NGitLab/Http/DefaultHttpMessageHandler.cs
new file mode 100644
index 000000000..2a5784fc7
--- /dev/null
+++ b/NGitLab/Http/DefaultHttpMessageHandler.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace NGitLab.Http;
+
+///
+/// Default implementation of that wraps HttpClient.
+///
+internal sealed class DefaultHttpMessageHandler : IHttpMessageHandler
+{
+ private readonly HttpClient _httpClient;
+
+ public DefaultHttpMessageHandler(HttpClient httpClient)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ public HttpResponseMessage Send(HttpRequestMessage request)
+ {
+#if NET8_0_OR_GREATER
+ return _httpClient.Send(request);
+#else
+ return _httpClient.SendAsync(request).GetAwaiter().GetResult();
+#endif
+ }
+
+ public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
+ {
+ return _httpClient.SendAsync(request, cancellationToken);
+ }
+}
diff --git a/NGitLab/Http/HttpClientManager.cs b/NGitLab/Http/HttpClientManager.cs
new file mode 100644
index 000000000..526ed3415
--- /dev/null
+++ b/NGitLab/Http/HttpClientManager.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Net;
+using System.Net.Http;
+
+namespace NGitLab.Http;
+
+///
+/// Manages the lifecycle of HttpClient instances.
+/// Uses a singleton HttpClient for default scenarios to improve connection pooling.
+/// Creates new instances only when custom configuration (proxy, timeout) is required.
+///
+internal static class HttpClientManager
+{
+ private static readonly Lazy s_defaultClient = new(() => CreateHttpClient(null, null));
+
+ ///
+ /// Gets the singleton HttpClient instance for default scenarios.
+ ///
+ public static HttpClient DefaultClient => s_defaultClient.Value;
+
+ ///
+ /// Creates a new HttpClient with the specified configuration.
+ ///
+ /// Optional proxy configuration.
+ /// Optional timeout value.
+ /// A configured HttpClient instance.
+ public static HttpClient CreateHttpClient(IWebProxy proxy, TimeSpan? timeout)
+ {
+ var handler = new HttpClientHandler
+ {
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
+ };
+
+ if (proxy != null)
+ {
+ handler.Proxy = proxy;
+ handler.UseProxy = true;
+ }
+
+ var client = new HttpClient(handler);
+
+ if (timeout.HasValue)
+ {
+ client.Timeout = timeout.Value;
+ }
+
+ return client;
+ }
+
+ ///
+ /// Gets or creates an HttpClient based on the provided options.
+ /// Returns the singleton instance if no custom configuration is needed.
+ ///
+ /// The request options containing proxy and timeout configuration.
+ /// An HttpClient instance.
+ public static HttpClient GetOrCreateHttpClient(RequestOptions options)
+ {
+ if (options.HttpClientFactory != null)
+ {
+ return options.HttpClientFactory(options);
+ }
+
+ // Use singleton if no custom proxy or timeout
+ if (options.Proxy == null && !options.HttpClientTimeout.HasValue)
+ {
+ return DefaultClient;
+ }
+
+ // Create new instance for custom configuration
+ return CreateHttpClient(options.Proxy, options.HttpClientTimeout);
+ }
+}
diff --git a/NGitLab/Http/HttpResponseHeadersAdapter.cs b/NGitLab/Http/HttpResponseHeadersAdapter.cs
new file mode 100644
index 000000000..e43bae695
--- /dev/null
+++ b/NGitLab/Http/HttpResponseHeadersAdapter.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+
+namespace NGitLab.Http;
+
+///
+/// Adapter that provides dictionary-like access to HTTP response headers.
+/// Replaces the obsolete WebHeadersDictionaryAdaptor.
+///
+internal sealed class HttpResponseHeadersAdapter : IDictionary
+{
+ private readonly HttpResponseMessage _response;
+
+ public HttpResponseHeadersAdapter(HttpResponseMessage response)
+ {
+ _response = response ?? throw new ArgumentNullException(nameof(response));
+ }
+
+ public string this[string key]
+ {
+ get
+ {
+ // Try response headers first
+ if (_response.Headers.TryGetValues(key, out var values))
+ {
+ return string.Join(",", values);
+ }
+
+ // Try content headers
+ if (_response.Content?.Headers != null && _response.Content.Headers.TryGetValues(key, out values))
+ {
+ return string.Join(",", values);
+ }
+
+ throw new KeyNotFoundException($"Header '{key}' not found.");
+ }
+ set => throw new NotSupportedException("Headers are read-only.");
+ }
+
+ public ICollection Keys
+ {
+ get
+ {
+ var keys = new List(_response.Headers.Select(h => h.Key));
+ if (_response.Content?.Headers != null)
+ {
+ keys.AddRange(_response.Content.Headers.Select(h => h.Key));
+ }
+
+ return keys;
+ }
+ }
+
+ public ICollection Values
+ {
+ get
+ {
+ var values = new List();
+ foreach (var header in _response.Headers)
+ {
+ values.Add(string.Join(",", header.Value));
+ }
+
+ if (_response.Content?.Headers != null)
+ {
+ foreach (var header in _response.Content.Headers)
+ {
+ values.Add(string.Join(",", header.Value));
+ }
+ }
+
+ return values;
+ }
+ }
+
+ public int Count => _response.Headers.Count() + (_response.Content?.Headers?.Count() ?? 0);
+
+ public bool IsReadOnly => true;
+
+ public void Add(string key, string value) => throw new NotSupportedException("Headers are read-only.");
+
+ public void Add(KeyValuePair item) => throw new NotSupportedException("Headers are read-only.");
+
+ public void Clear() => throw new NotSupportedException("Headers are read-only.");
+
+ public bool Contains(KeyValuePair item)
+ {
+ if (_response.Headers.TryGetValues(item.Key, out var values))
+ {
+ return string.Equals(string.Join(",", values), item.Value, StringComparison.Ordinal);
+ }
+
+ if (_response.Content?.Headers != null && _response.Content.Headers.TryGetValues(item.Key, out values))
+ {
+ return string.Equals(string.Join(",", values), item.Value, StringComparison.Ordinal);
+ }
+
+ return false;
+ }
+
+ public bool ContainsKey(string key)
+ {
+ return _response.Headers.Contains(key) ||
+ (_response.Content?.Headers?.Contains(key) ?? false);
+ }
+
+ public void CopyTo(KeyValuePair[] array, int arrayIndex)
+ {
+ if (array == null)
+ throw new ArgumentNullException(nameof(array));
+
+ var index = arrayIndex;
+ foreach (var header in _response.Headers)
+ {
+ array[index++] = new KeyValuePair(header.Key, string.Join(",", header.Value));
+ }
+
+ if (_response.Content?.Headers != null)
+ {
+ foreach (var header in _response.Content.Headers)
+ {
+ array[index++] = new KeyValuePair(header.Key, string.Join(",", header.Value));
+ }
+ }
+ }
+
+ public IEnumerator> GetEnumerator()
+ {
+ foreach (var header in _response.Headers)
+ {
+ yield return new KeyValuePair(header.Key, string.Join(",", header.Value));
+ }
+
+ if (_response.Content?.Headers != null)
+ {
+ foreach (var header in _response.Content.Headers)
+ {
+ yield return new KeyValuePair(header.Key, string.Join(",", header.Value));
+ }
+ }
+ }
+
+ public bool Remove(string key) => throw new NotSupportedException("Headers are read-only.");
+
+ public bool Remove(KeyValuePair item) => throw new NotSupportedException("Headers are read-only.");
+
+ public bool TryGetValue(string key, out string value)
+ {
+ if (_response.Headers.TryGetValues(key, out var values))
+ {
+ value = string.Join(",", values);
+ return true;
+ }
+
+ if (_response.Content?.Headers != null && _response.Content.Headers.TryGetValues(key, out values))
+ {
+ value = string.Join(",", values);
+ return true;
+ }
+
+ value = null!;
+ return false;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/NGitLab/Http/IHttpMessageHandler.cs b/NGitLab/Http/IHttpMessageHandler.cs
new file mode 100644
index 000000000..42dc782ba
--- /dev/null
+++ b/NGitLab/Http/IHttpMessageHandler.cs
@@ -0,0 +1,27 @@
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace NGitLab.Http;
+
+///
+/// Provides an extensibility point for customizing HTTP request behavior.
+/// This interface replaces the obsolete virtual methods on .
+///
+public interface IHttpMessageHandler
+{
+ ///
+ /// Sends an HTTP request synchronously.
+ ///
+ /// The HTTP request message to send.
+ /// The HTTP response message.
+ HttpResponseMessage Send(HttpRequestMessage request);
+
+ ///
+ /// Sends an HTTP request asynchronously.
+ ///
+ /// The HTTP request message to send.
+ /// A cancellation token to cancel the operation.
+ /// A task representing the asynchronous operation, containing the HTTP response message.
+ Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default);
+}
diff --git a/NGitLab/Impl/HttpRequestor.GitLabRequest.cs b/NGitLab/Impl/HttpRequestor.GitLabRequest.cs
index 6ed69aab2..afd36f876 100644
--- a/NGitLab/Impl/HttpRequestor.GitLabRequest.cs
+++ b/NGitLab/Impl/HttpRequestor.GitLabRequest.cs
@@ -1,12 +1,14 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
+using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Extensions;
+using NGitLab.Http;
using NGitLab.Impl.Json;
using NGitLab.Models;
@@ -31,21 +33,29 @@ private sealed class GitLabRequest
private MethodType Method { get; }
- public WebHeaderCollection Headers { get; } = [];
+ private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly IHttpMessageHandler _messageHandler;
+ private readonly RequestOptions _options;
private bool HasOutput
=> (Method == MethodType.Delete || Method == MethodType.Post || Method == MethodType.Put || Method == MethodType.Patch)
&& Data != null;
- public GitLabRequest(Uri url, MethodType method, object data, string apiToken, RequestOptions options = null)
+ public GitLabRequest(Uri url, MethodType method, object data, string apiToken, RequestOptions options, IHttpMessageHandler messageHandler)
{
Method = method;
Url = url;
Data = data;
- Headers.Add("Accept-Encoding", "gzip");
+ _messageHandler = messageHandler;
+ _options = options ?? RequestOptions.Default;
+
+ // Add headers
+ _headers.Add("Accept-Encoding", "gzip");
+
if (!string.IsNullOrEmpty(options?.Sudo))
{
- Headers.Add("Sudo", options.Sudo);
+ _headers.Add("Sudo", options.Sudo);
}
if (apiToken != null)
@@ -54,12 +64,13 @@ public GitLabRequest(Uri url, MethodType method, object data, string apiToken, R
// personal, project, group and OAuth tokens. The 'PRIVATE-TOKEN' header does not
// provide OAuth token support.
// Reference: https://docs.gitlab.com/ee/api/rest/#personalprojectgroup-access-tokens
- Headers.Add(HttpRequestHeader.Authorization, string.Concat("Bearer ", apiToken));
+ var authValue = string.Concat("Bearer ", apiToken);
+ _headers.Add("Authorization", authValue);
}
if (!string.IsNullOrEmpty(options?.UserAgent))
{
- Headers.Add("User-Agent", options.UserAgent);
+ _headers.Add("User-Agent", options.UserAgent);
}
if (data is FormDataContent formData)
@@ -100,15 +111,19 @@ private WebResponse GetResponseImpl(RequestOptions options)
{
try
{
- var request = CreateRequest(options);
- return options.GetResponse(request);
+ var request = CreateHttpRequestMessage();
+ var response = _messageHandler.Send(request);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ HandleHttpResponseError(response);
+ }
+
+ return new HttpResponseMessageWrapper(response);
}
- catch (WebException wex)
+ catch (HttpRequestException ex)
{
- if (wex.Response == null)
- throw;
-
- HandleWebException(wex);
+ HandleHttpRequestException(ex);
throw;
}
}
@@ -117,31 +132,102 @@ private async Task GetResponseImplAsync(RequestOptions options, Can
{
try
{
- var request = CreateRequest(options);
- return await options.GetResponseAsync(request, cancellationToken).ConfigureAwait(false);
+ var request = CreateHttpRequestMessage();
+ var response = await _messageHandler.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ await HandleHttpResponseErrorAsync(response).ConfigureAwait(false);
+ }
+
+ return new HttpResponseMessageWrapper(response);
}
- catch (WebException wex)
+ catch (HttpRequestException ex)
{
- if (wex.Response == null)
- throw;
-
- HandleWebException(wex);
+ HandleHttpRequestException(ex);
throw;
}
}
- private void HandleWebException(WebException ex)
+ private HttpRequestMessage CreateHttpRequestMessage()
+ {
+ var request = new HttpRequestMessage(GetHttpMethod(), Url);
+
+ // Add headers
+ foreach (var header in _headers)
+ {
+ request.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ request.Headers.Accept.Clear();
+ request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
+
+ // Add content
+ if (HasOutput)
+ {
+ if (FormData != null)
+ {
+ request.Content = CreateFileContent();
+ }
+ else if (UrlEncodedData != null)
+ {
+ request.Content = new FormUrlEncodedContent(UrlEncodedData.Values);
+ }
+ else if (JsonData != null)
+ {
+ request.Content = new StringContent(JsonData, Encoding.UTF8, "application/json");
+ }
+ }
+ else if (Method == MethodType.Put)
+ {
+ request.Content = new StringContent(string.Empty);
+ }
+
+ return request;
+ }
+
+ private HttpMethod GetHttpMethod()
+ {
+ return Method switch
+ {
+ MethodType.Get => HttpMethod.Get,
+ MethodType.Post => HttpMethod.Post,
+ MethodType.Put => HttpMethod.Put,
+ MethodType.Delete => HttpMethod.Delete,
+ MethodType.Head => HttpMethod.Head,
+ MethodType.Options => HttpMethod.Options,
+#if NET8_0_OR_GREATER
+ MethodType.Patch => HttpMethod.Patch,
+#else
+ MethodType.Patch => new HttpMethod("PATCH"),
+#endif
+ _ => throw new NotSupportedException($"HTTP method {Method} is not supported."),
+ };
+ }
+
+ private MultipartFormDataContent CreateFileContent()
+ {
+ if (Data is not FormDataContent formData)
+ return null;
+
+ var boundary = $"--------------------------{DateTime.UtcNow.Ticks.ToStringInvariant()}";
+ var content = new MultipartFormDataContent(boundary);
+ content.Add(new StreamContent(formData.Stream), "file", formData.Name);
+ return content;
+ }
+
+ private void HandleHttpResponseError(HttpResponseMessage response)
{
- using var errorResponse = (HttpWebResponse)ex.Response;
string jsonString;
- using (var reader = new StreamReader(errorResponse.GetResponseStream()))
+ using (var stream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult())
+ using (var reader = new StreamReader(stream))
{
jsonString = reader.ReadToEnd();
}
var errorMessage = ExtractErrorMessage(jsonString, out var errorDetails);
var exceptionMessage =
- $"GitLab server returned an error ({errorResponse.StatusCode}): {errorMessage}. " +
+ $"GitLab server returned an error ({response.StatusCode}): {errorMessage}. " +
$"Original call: {Method} {Url}";
if (JsonData != null)
@@ -149,84 +235,79 @@ private void HandleWebException(WebException ex)
exceptionMessage += $". With data {JsonData}";
}
+ response.Dispose();
+
throw new GitLabException(exceptionMessage)
{
OriginalCall = Url,
ErrorObject = errorDetails,
- StatusCode = errorResponse.StatusCode,
+ StatusCode = response.StatusCode,
ErrorMessage = errorMessage,
MethodType = Method,
};
}
- private HttpWebRequest CreateRequest(RequestOptions options)
+ private async Task HandleHttpResponseErrorAsync(HttpResponseMessage response)
{
- var request = WebRequest.CreateHttp(Url);
- request.Method = Method.ToString().ToUpperInvariant();
- request.Accept = "application/json";
- request.Headers = Headers;
- request.AutomaticDecompression = DecompressionMethods.GZip;
- request.Timeout = (int)options.HttpClientTimeout.TotalMilliseconds;
- request.ReadWriteTimeout = (int)options.HttpClientTimeout.TotalMilliseconds;
- if (options.Proxy != null)
+ string jsonString;
+ using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
+ using (var reader = new StreamReader(stream))
{
- request.Proxy = options.Proxy;
+ jsonString = await reader.ReadToEndAsync().ConfigureAwait(false);
}
- if (HasOutput)
- {
- if (FormData != null)
- {
- AddFileData(request, options);
- }
- else if (UrlEncodedData != null)
- {
- AddUrlEncodedData(request, options);
- }
- else if (JsonData != null)
- {
- AddJsonData(request, options);
- }
- }
- else if (Method == MethodType.Put)
+ var errorMessage = ExtractErrorMessage(jsonString, out var errorDetails);
+ var exceptionMessage =
+ $"GitLab server returned an error ({response.StatusCode}): {errorMessage}. " +
+ $"Original call: {Method} {Url}";
+
+ if (JsonData != null)
{
- request.ContentLength = 0;
+ exceptionMessage += $". With data {JsonData}";
}
- return request;
- }
+ response.Dispose();
- private void AddJsonData(HttpWebRequest request, RequestOptions options)
- {
- request.ContentType = "application/json";
-
- using var writer = new StreamWriter(options.GetRequestStream(request));
- writer.Write(JsonData);
- writer.Flush();
- writer.Close();
+ throw new GitLabException(exceptionMessage)
+ {
+ OriginalCall = Url,
+ ErrorObject = errorDetails,
+ StatusCode = response.StatusCode,
+ ErrorMessage = errorMessage,
+ MethodType = Method,
+ };
}
- public void AddFileData(HttpWebRequest request, RequestOptions options)
+ private void HandleHttpRequestException(HttpRequestException ex)
{
- var boundary = $"--------------------------{DateTime.UtcNow.Ticks.ToStringInvariant()}";
- if (Data is not FormDataContent formData)
- return;
- request.ContentType = "multipart/form-data; boundary=" + boundary;
+ // Handle connection-level errors (no response received)
+ HttpStatusCode statusCode = HttpStatusCode.InternalServerError;
- using var uploadContent = new MultipartFormDataContent(boundary)
+#if NET8_0_OR_GREATER
+ // HttpRequestException in .NET 5+ includes StatusCode
+ if (ex.StatusCode.HasValue)
{
- { new StreamContent(formData.Stream), "file", formData.Name },
- };
+ statusCode = ex.StatusCode.Value;
+ }
+#endif
- uploadContent.CopyToAsync(options.GetRequestStream(request)).Wait();
- }
+ var exceptionMessage =
+ $"GitLab server returned an error ({statusCode}): {ex.Message}. " +
+ $"Original call: {Method} {Url}";
- public void AddUrlEncodedData(HttpWebRequest request, RequestOptions options)
- {
- request.ContentType = "application/x-www-form-urlencoded";
+ if (JsonData != null)
+ {
+ exceptionMessage += $". With data {JsonData}";
+ }
- using var content = new FormUrlEncodedContent(UrlEncodedData.Values);
- content.CopyToAsync(options.GetRequestStream(request)).Wait();
+ throw new GitLabException(exceptionMessage, ex)
+ {
+ OriginalCall = Url,
+ ErrorObject = null,
+ StatusCode = statusCode,
+ ErrorMessage = ex.Message,
+ MethodType = Method,
+ };
}
///
@@ -266,5 +347,62 @@ private static string ExtractErrorMessage(string json, out IDictionary
+ /// Wrapper to make HttpResponseMessage compatible with WebResponse API
+ ///
+ private sealed class HttpResponseMessageWrapper : WebResponse
+ {
+ private readonly HttpResponseMessage _response;
+
+ public HttpResponseMessageWrapper(HttpResponseMessage response)
+ {
+ _response = response ?? throw new ArgumentNullException(nameof(response));
+ }
+
+ public override WebHeaderCollection Headers
+ {
+ get
+ {
+ var headers = new WebHeaderCollection();
+ foreach (var header in _response.Headers)
+ {
+ foreach (var value in header.Value)
+ {
+ headers.Add(header.Key, value);
+ }
+ }
+
+ if (_response.Content?.Headers != null)
+ {
+ foreach (var header in _response.Content.Headers)
+ {
+ foreach (var value in header.Value)
+ {
+ headers.Add(header.Key, value);
+ }
+ }
+ }
+
+ return headers;
+ }
+ }
+
+ public override Stream GetResponseStream()
+ {
+ // ReadAsStreamAsync returns a stream that can be read synchronously
+ return _response.Content?.ReadAsStreamAsync().GetAwaiter().GetResult();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _response?.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
}
}
diff --git a/NGitLab/Impl/HttpRequestor.cs b/NGitLab/Impl/HttpRequestor.cs
index aaf1f874a..4ff22f493 100644
--- a/NGitLab/Impl/HttpRequestor.cs
+++ b/NGitLab/Impl/HttpRequestor.cs
@@ -5,6 +5,7 @@
using System.Net;
using System.Threading;
using System.Threading.Tasks;
+using NGitLab.Http;
using NGitLab.Impl.Json;
using NGitLab.Models;
@@ -21,14 +22,7 @@ public partial class HttpRequestor : IHttpRequestor
private readonly string _apiToken;
private readonly string _hostUrl;
-
- static HttpRequestor()
- {
- // By default only Sssl and Tls 1.0 is enabled with .NET 4.5
- // We add Tls 1.2 and Tls 1.2 without affecting the other values in case new protocols are added in the future
- // (see https://stackoverflow.com/questions/28286086/default-securityprotocol-in-net-4-5)
- ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
- }
+ private readonly IHttpMessageHandler _messageHandler;
public HttpRequestor(string hostUrl, string apiToken, MethodType methodType)
: this(hostUrl, apiToken, methodType, RequestOptions.Default)
@@ -41,6 +35,19 @@ public HttpRequestor(string hostUrl, string apiToken, MethodType methodType, Req
_apiToken = apiToken;
_methodType = methodType;
_options = options;
+
+ // Initialize message handler
+ if (options.MessageHandler != null)
+ {
+ // User provided custom message handler
+ _messageHandler = options.MessageHandler;
+ }
+ else
+ {
+ // Use default HttpClient-based handler
+ var httpClient = HttpClientManager.GetOrCreateHttpClient(options);
+ _messageHandler = new DefaultHttpMessageHandler(httpClient);
+ }
}
public IHttpRequestor With(object data)
@@ -139,13 +146,13 @@ public virtual void Stream(string tailAPIUrl, Action parser) =>
public virtual void StreamAndHeaders(string tailAPIUrl, Action>> parser)
{
- var request = new GitLabRequest(GetAPIUrl(tailAPIUrl), _methodType, _data, _apiToken, _options);
+ var request = new GitLabRequest(GetAPIUrl(tailAPIUrl), _methodType, _data, _apiToken, _options, _messageHandler);
using var response = request.GetResponse(_options);
if (parser != null)
{
using var stream = response.GetResponseStream();
- parser(stream, new WebHeadersDictionaryAdaptor(response.Headers));
+ parser(stream, ConvertHeaders(response.Headers));
}
}
@@ -154,24 +161,35 @@ public virtual Task StreamAsync(string tailAPIUrl, Func parser, Ca
public virtual async Task StreamAndHeadersAsync(string tailAPIUrl, Func>, Task> parser, CancellationToken cancellationToken)
{
- var request = new GitLabRequest(GetAPIUrl(tailAPIUrl), _methodType, _data, _apiToken, _options);
+ var request = new GitLabRequest(GetAPIUrl(tailAPIUrl), _methodType, _data, _apiToken, _options, _messageHandler);
using var response = await request.GetResponseAsync(_options, cancellationToken).ConfigureAwait(false);
if (parser != null)
{
using var stream = response.GetResponseStream();
- await parser(stream, new WebHeadersDictionaryAdaptor(response.Headers)).ConfigureAwait(false);
+ await parser(stream, ConvertHeaders(response.Headers)).ConfigureAwait(false);
}
}
+ private static IReadOnlyDictionary> ConvertHeaders(WebHeaderCollection headers)
+ {
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (string key in headers.AllKeys)
+ {
+ result[key] = headers.GetValues(key) ?? Array.Empty();
+ }
+
+ return result;
+ }
+
public virtual IEnumerable GetAll(string tailUrl)
{
- return new Enumerable(_apiToken, GetAPIUrl(tailUrl), _options);
+ return new Enumerable(_apiToken, GetAPIUrl(tailUrl), _options, _messageHandler);
}
public virtual GitLabCollectionResponse GetAllAsync(string tailUrl)
{
- return new Enumerable(_apiToken, GetAPIUrl(tailUrl), _options);
+ return new Enumerable(_apiToken, GetAPIUrl(tailUrl), _options, _messageHandler);
}
private static string ReadText(Stream s)
@@ -196,11 +214,14 @@ internal sealed class Enumerable : GitLabCollectionResponse
private readonly RequestOptions _options;
private readonly Uri _startUrl;
- internal Enumerable(string apiToken, Uri startUrl, RequestOptions options)
+ private readonly IHttpMessageHandler _messageHandler;
+
+ internal Enumerable(string apiToken, Uri startUrl, RequestOptions options, IHttpMessageHandler messageHandler)
{
_apiToken = apiToken;
_startUrl = startUrl;
_options = options;
+ _messageHandler = messageHandler;
}
public override async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default)
@@ -209,7 +230,7 @@ public override async IAsyncEnumerator GetAsyncEnumerator(CancellationToken c
while (nextUrlToLoad != null)
{
string responseText;
- var request = new GitLabRequest(nextUrlToLoad, MethodType.Get, data: null, _apiToken, _options);
+ var request = new GitLabRequest(nextUrlToLoad, MethodType.Get, data: null, _apiToken, _options, _messageHandler);
using (var response = await request.GetResponseAsync(_options, cancellationToken).ConfigureAwait(false))
{
nextUrlToLoad = GetNextPageUrl(response);
@@ -230,7 +251,7 @@ public override IEnumerator GetEnumerator()
while (nextUrlToLoad != null)
{
string responseText;
- var request = new GitLabRequest(nextUrlToLoad, MethodType.Get, data: null, _apiToken, _options);
+ var request = new GitLabRequest(nextUrlToLoad, MethodType.Get, data: null, _apiToken, _options, _messageHandler);
using (var response = request.GetResponse(_options))
{
nextUrlToLoad = GetNextPageUrl(response);
diff --git a/NGitLab/Impl/WebHeadersDictionaryAdaptor.cs b/NGitLab/Impl/WebHeadersDictionaryAdaptor.cs
deleted file mode 100644
index 30ab2934c..000000000
--- a/NGitLab/Impl/WebHeadersDictionaryAdaptor.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net;
-
-namespace NGitLab.Impl;
-
-///
-/// A wrapper around a .
-/// The purpose of this class is to keep the WebHeaderCollection type out of the public interface.
-///
-internal sealed class WebHeadersDictionaryAdaptor : IReadOnlyDictionary>
-{
- private readonly WebHeaderCollection _headers;
- private IEnumerable[] _cachedValues;
-
- public WebHeadersDictionaryAdaptor(WebHeaderCollection headers)
- {
- _headers = headers ?? throw new ArgumentNullException(nameof(headers));
- }
-
- public int Count => _headers.Count;
-
- public IEnumerable this[string key] => _headers.GetValues(key) ?? throw new InvalidOperationException("Header not found");
-
- public IEnumerable Keys => _headers.AllKeys;
-
- public IEnumerable> Values => _cachedValues ??= this.Select(p => p.Value).ToArray();
-
- public bool ContainsKey(string key) => _headers.GetValues(key) is not null;
-
- public bool TryGetValue(string key, out IEnumerable value) => (value = _headers.GetValues(key)) is not null;
-
- public IEnumerator>> GetEnumerator() => AsEnumerable().GetEnumerator();
-
- IEnumerator IEnumerable.GetEnumerator() => AsEnumerable().GetEnumerator();
-
- private IEnumerable>> AsEnumerable()
- {
- foreach (var key in _headers.AllKeys)
- {
- // DO NOT use _headers.GetValues(int index) -- it does not return the expected value.
- yield return new(key, _headers.GetValues(key) ?? Enumerable.Empty());
- }
- }
-}
diff --git a/NGitLab/NGitLab.csproj b/NGitLab/NGitLab.csproj
index b28bb0e4f..05a603876 100644
--- a/NGitLab/NGitLab.csproj
+++ b/NGitLab/NGitLab.csproj
@@ -1,8 +1,6 @@
net472;netstandard2.0;net8.0
-
- $(WarningsNotAsErrors);SYSLIB0014
diff --git a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt
index bf8834736..c6fca26ba 100644
--- a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt
+++ b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt
@@ -125,6 +125,8 @@ NGitLab.GitLabException.OriginalCall.get -> System.Uri
NGitLab.GitLabException.OriginalCall.set -> void
NGitLab.GitLabException.StatusCode.get -> System.Net.HttpStatusCode
NGitLab.GitLabException.StatusCode.set -> void
+NGitLab.Http.IHttpMessageHandler.Send(System.Net.Http.HttpRequestMessage request) -> System.Net.Http.HttpResponseMessage
+NGitLab.Http.IHttpMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
NGitLab.IBranchClient
NGitLab.IBranchClient.All.get -> System.Collections.Generic.IEnumerable
NGitLab.IBranchClient.Create(NGitLab.Models.BranchCreate branch) -> NGitLab.Models.Branch
@@ -5071,7 +5073,7 @@ NGitLab.Models.WikiPageUpdate.Title.get -> string
NGitLab.Models.WikiPageUpdate.Title.set -> void
NGitLab.Models.WikiPageUpdate.WikiPageUpdate() -> void
NGitLab.RequestOptions
-NGitLab.RequestOptions.HttpClientTimeout.get -> System.TimeSpan
+NGitLab.RequestOptions.HttpClientTimeout.get -> System.TimeSpan?
NGitLab.RequestOptions.HttpClientTimeout.set -> void
NGitLab.RequestOptions.IsIncremental.get -> bool
NGitLab.RequestOptions.IsIncremental.set -> void
@@ -5214,6 +5216,9 @@ virtual NGitLab.Impl.HttpRequestor.StreamAndHeadersAsync(string tailAPIUrl, Syst
virtual NGitLab.Impl.HttpRequestor.StreamAsync(string tailAPIUrl, System.Func parser, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
virtual NGitLab.Impl.HttpRequestor.To(string tailAPIUrl) -> T
virtual NGitLab.Impl.HttpRequestor.ToAsync(string tailAPIUrl, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
-virtual NGitLab.RequestOptions.GetResponse(System.Net.HttpWebRequest request) -> System.Net.WebResponse
-virtual NGitLab.RequestOptions.GetResponseAsync(System.Net.HttpWebRequest request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
virtual NGitLab.RequestOptions.ShouldRetry(System.Exception ex, int retryNumber) -> bool
+NGitLab.Http.IHttpMessageHandler
+NGitLab.RequestOptions.MessageHandler.get -> NGitLab.Http.IHttpMessageHandler
+NGitLab.RequestOptions.MessageHandler.set -> void
+NGitLab.RequestOptions.HttpClientFactory.get -> System.Func
+NGitLab.RequestOptions.HttpClientFactory.set -> void
diff --git a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index 78fea3cbb..a86e74987 100644
--- a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -124,6 +124,8 @@ NGitLab.GitLabException.OriginalCall.get -> System.Uri
NGitLab.GitLabException.OriginalCall.set -> void
NGitLab.GitLabException.StatusCode.get -> System.Net.HttpStatusCode
NGitLab.GitLabException.StatusCode.set -> void
+NGitLab.Http.IHttpMessageHandler.Send(System.Net.Http.HttpRequestMessage request) -> System.Net.Http.HttpResponseMessage
+NGitLab.Http.IHttpMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
NGitLab.IBranchClient
NGitLab.IBranchClient.All.get -> System.Collections.Generic.IEnumerable
NGitLab.IBranchClient.Create(NGitLab.Models.BranchCreate branch) -> NGitLab.Models.Branch
@@ -5070,7 +5072,7 @@ NGitLab.Models.WikiPageUpdate.Title.get -> string
NGitLab.Models.WikiPageUpdate.Title.set -> void
NGitLab.Models.WikiPageUpdate.WikiPageUpdate() -> void
NGitLab.RequestOptions
-NGitLab.RequestOptions.HttpClientTimeout.get -> System.TimeSpan
+NGitLab.RequestOptions.HttpClientTimeout.get -> System.TimeSpan?
NGitLab.RequestOptions.HttpClientTimeout.set -> void
NGitLab.RequestOptions.IsIncremental.get -> bool
NGitLab.RequestOptions.IsIncremental.set -> void
@@ -5213,6 +5215,9 @@ virtual NGitLab.Impl.HttpRequestor.StreamAndHeadersAsync(string tailAPIUrl, Syst
virtual NGitLab.Impl.HttpRequestor.StreamAsync(string tailAPIUrl, System.Func parser, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
virtual NGitLab.Impl.HttpRequestor.To(string tailAPIUrl) -> T
virtual NGitLab.Impl.HttpRequestor.ToAsync(string tailAPIUrl, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
-virtual NGitLab.RequestOptions.GetResponse(System.Net.HttpWebRequest request) -> System.Net.WebResponse
-virtual NGitLab.RequestOptions.GetResponseAsync(System.Net.HttpWebRequest request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
virtual NGitLab.RequestOptions.ShouldRetry(System.Exception ex, int retryNumber) -> bool
+NGitLab.Http.IHttpMessageHandler
+NGitLab.RequestOptions.MessageHandler.get -> NGitLab.Http.IHttpMessageHandler
+NGitLab.RequestOptions.MessageHandler.set -> void
+NGitLab.RequestOptions.HttpClientFactory.get -> System.Func
+NGitLab.RequestOptions.HttpClientFactory.set -> void
diff --git a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index bf8834736..c6fca26ba 100644
--- a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -125,6 +125,8 @@ NGitLab.GitLabException.OriginalCall.get -> System.Uri
NGitLab.GitLabException.OriginalCall.set -> void
NGitLab.GitLabException.StatusCode.get -> System.Net.HttpStatusCode
NGitLab.GitLabException.StatusCode.set -> void
+NGitLab.Http.IHttpMessageHandler.Send(System.Net.Http.HttpRequestMessage request) -> System.Net.Http.HttpResponseMessage
+NGitLab.Http.IHttpMessageHandler.SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
NGitLab.IBranchClient
NGitLab.IBranchClient.All.get -> System.Collections.Generic.IEnumerable
NGitLab.IBranchClient.Create(NGitLab.Models.BranchCreate branch) -> NGitLab.Models.Branch
@@ -5071,7 +5073,7 @@ NGitLab.Models.WikiPageUpdate.Title.get -> string
NGitLab.Models.WikiPageUpdate.Title.set -> void
NGitLab.Models.WikiPageUpdate.WikiPageUpdate() -> void
NGitLab.RequestOptions
-NGitLab.RequestOptions.HttpClientTimeout.get -> System.TimeSpan
+NGitLab.RequestOptions.HttpClientTimeout.get -> System.TimeSpan?
NGitLab.RequestOptions.HttpClientTimeout.set -> void
NGitLab.RequestOptions.IsIncremental.get -> bool
NGitLab.RequestOptions.IsIncremental.set -> void
@@ -5214,6 +5216,9 @@ virtual NGitLab.Impl.HttpRequestor.StreamAndHeadersAsync(string tailAPIUrl, Syst
virtual NGitLab.Impl.HttpRequestor.StreamAsync(string tailAPIUrl, System.Func parser, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
virtual NGitLab.Impl.HttpRequestor.To(string tailAPIUrl) -> T
virtual NGitLab.Impl.HttpRequestor.ToAsync(string tailAPIUrl, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
-virtual NGitLab.RequestOptions.GetResponse(System.Net.HttpWebRequest request) -> System.Net.WebResponse
-virtual NGitLab.RequestOptions.GetResponseAsync(System.Net.HttpWebRequest request, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
virtual NGitLab.RequestOptions.ShouldRetry(System.Exception ex, int retryNumber) -> bool
+NGitLab.Http.IHttpMessageHandler
+NGitLab.RequestOptions.MessageHandler.get -> NGitLab.Http.IHttpMessageHandler
+NGitLab.RequestOptions.MessageHandler.set -> void
+NGitLab.RequestOptions.HttpClientFactory.get -> System.Func
+NGitLab.RequestOptions.HttpClientFactory.set -> void
diff --git a/NGitLab/RequestOptions.cs b/NGitLab/RequestOptions.cs
index fc003d4c2..629912e16 100644
--- a/NGitLab/RequestOptions.cs
+++ b/NGitLab/RequestOptions.cs
@@ -1,8 +1,7 @@
using System;
-using System.IO;
using System.Net;
-using System.Threading;
-using System.Threading.Tasks;
+using System.Net.Http;
+using NGitLab.Http;
using NGitLab.Impl;
namespace NGitLab;
@@ -29,12 +28,25 @@ public class RequestOptions
/// GitLab exposes some end points which are really slow so
/// the default we use is larger than the default 100 seconds of .net
///
- public TimeSpan HttpClientTimeout { get; set; } = TimeSpan.FromMinutes(5);
+ public TimeSpan? HttpClientTimeout { get; set; } = TimeSpan.FromMinutes(5);
public string UserAgent { get; set; }
public WebProxy Proxy { get; set; }
+ ///
+ /// Custom HTTP message handler for extensibility.
+ /// Set this property to customize HTTP request behavior (e.g., logging, throttling).
+ /// This replaces the obsolete virtual methods GetResponse and GetResponseAsync.
+ ///
+ public IHttpMessageHandler MessageHandler { get; set; }
+
+ ///
+ /// Custom factory for creating HttpClient instances.
+ /// If set, this factory will be used instead of the default HttpClient management.
+ ///
+ public Func HttpClientFactory { get; set; }
+
public RequestOptions(int retryCount, TimeSpan retryInterval, bool isIncremental = true)
{
RetryCount = retryCount;
@@ -71,28 +83,4 @@ public virtual bool ShouldRetry(Exception ex, int retryNumber)
}
public static RequestOptions Default => new(retryCount: 0, retryInterval: TimeSpan.Zero);
-
- ///
- /// Allows to monitor the web requests from the caller library, for example
- /// to log the request duration and debug the library.
- ///
- public virtual WebResponse GetResponse(HttpWebRequest request)
- {
- return request.GetResponse();
- }
-
- public virtual async Task GetResponseAsync(HttpWebRequest request, CancellationToken cancellationToken)
- {
- using var cancellationTokenRegistration =
- cancellationToken.CanBeCanceled
- ? cancellationToken.Register(request.Abort)
- : default;
-
- return await request.GetResponseAsync().ConfigureAwait(false);
- }
-
- internal virtual Stream GetRequestStream(HttpWebRequest request)
- {
- return request.GetRequestStream();
- }
}