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(); - } }