From c965a163f910761f37c87cf0fd6d7c3ec6d3cf0b Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Sat, 7 Mar 2026 10:30:18 +0100 Subject: [PATCH 1/4] testable 1.7.1 --- Frends.HTTP.Request/CHANGELOG.md | 4 + .../Frends.HTTP.Request.Tests.csproj | 4 +- .../MockHttpClientFactory.cs | 19 -- .../Frends.HTTP.Request.Tests/UnitTests.cs | 196 ++++----------- .../Definitions/IHttpClientFactory.cs | 14 -- .../Frends.HTTP.Request/Extensions.cs | 65 +++-- .../Frends.HTTP.Request.csproj | 2 +- .../Frends.HTTP.Request/HttpClientFactory.cs | 14 -- .../Frends.HTTP.Request/Request.cs | 237 +++++++++++------- 9 files changed, 227 insertions(+), 328 deletions(-) delete mode 100644 Frends.HTTP.Request/Frends.HTTP.Request.Tests/MockHttpClientFactory.cs delete mode 100644 Frends.HTTP.Request/Frends.HTTP.Request/Definitions/IHttpClientFactory.cs delete mode 100644 Frends.HTTP.Request/Frends.HTTP.Request/HttpClientFactory.cs diff --git a/Frends.HTTP.Request/CHANGELOG.md b/Frends.HTTP.Request/CHANGELOG.md index c349f64..8e4cb47 100644 --- a/Frends.HTTP.Request/CHANGELOG.md +++ b/Frends.HTTP.Request/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.8.0] - 2026-03-07 +### Fixed +- Improve handling disposable objects to avoid problems with timeouts related to HttpClient. + ## [1.7.0] - 2026-03-03 ### Added - Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj index b882c61..73c1795 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/Frends.HTTP.Request.Tests.csproj @@ -18,12 +18,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - \ No newline at end of file + diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/MockHttpClientFactory.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/MockHttpClientFactory.cs deleted file mode 100644 index 5d70505..0000000 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/MockHttpClientFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Net.Http; -using Frends.HTTP.Request.Definitions; -using RichardSzalay.MockHttp; - -namespace Frends.HTTP.Request.Tests; - -public class MockHttpClientFactory : IHttpClientFactory -{ - private readonly MockHttpMessageHandler _mockHttpMessageHandler; - - public MockHttpClientFactory(MockHttpMessageHandler mockHttpMessageHandler) - { - _mockHttpMessageHandler = mockHttpMessageHandler; - } - public HttpClient CreateClient(Options options) - { - return _mockHttpMessageHandler.ToHttpClient(); - } -} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs index 79512d9..1b2c78f 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Threading; @@ -7,10 +6,9 @@ using Frends.HTTP.Request.Definitions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Method = Frends.HTTP.Request.Definitions; -using RichardSzalay.MockHttp; using Assert = NUnit.Framework.Assert; using System.Net; -using System.Text; +using System.Security.Cryptography.X509Certificates; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -21,15 +19,12 @@ namespace Frends.HTTP.Request.Tests; [TestClass] public class UnitTests { - private const string BasePath = "http://localhost:9191"; - private MockHttpMessageHandler _mockHttpMessageHandler; + private const string BasePath = "https://httpbin.org"; [TestInitialize] public void TestInitialize() { - _mockHttpMessageHandler = new MockHttpMessageHandler(); HTTP.ClearClientCache(); - HTTP.ClientFactory = new MockHttpClientFactory(_mockHttpMessageHandler); } private static Input GetInputParams(Method.Method method = Method.Method.GET, string url = BasePath, @@ -48,58 +43,16 @@ private static Input GetInputParams(Method.Method method = Method.Method.GET, st [TestMethod] public async Task RequestTestGetWithParameters() { - const string expectedReturn = @"'FooBar'"; - var dict = new Dictionary() - { - { - "foo", "bar" - }, - { - "bar", "foo" - } - }; - - _mockHttpMessageHandler.When($"{BasePath}/endpoint").WithQueryString(dict) - .Respond("application/json", expectedReturn); - - var input = GetInputParams(url: "http://localhost:9191/endpoint?foo=bar&bar=foo"); + var expected = "\"args\": {\n \"id\": \"2\", \n \"userId\": \"1\"\n }"; + var input = GetInputParams(url: $"{BasePath}/anything?id=2&userId=1"); var options = new Options { - ConnectionTimeoutSeconds = 60 + ConnectionTimeoutSeconds = 60, }; - var result = (dynamic)await HTTP.Request(input, options, CancellationToken.None); - - ClassicAssert.IsTrue(result.Body.Contains("FooBar")); - } - - [TestMethod] - public async Task RequestTestGetWithContent() - { - const string expectedReturn = "OK"; - _mockHttpMessageHandler.When($"{BasePath}/endpoint").WithHeaders("Content-Type", "text/plain") - .Respond("text/plain", expectedReturn); - - var contentType = new Header - { - Name = "Content-Type", - Value = "text/plain" - }; - var input = GetInputParams( - url: "http://localhost:9191/endpoint", - method: Method.Method.GET, - headers: new Header[1] - { - contentType - } - ); - var options = new Options - { - ConnectionTimeoutSeconds = 60 - }; + var result = await HTTP.Request(input, options, CancellationToken.None); - var result = (dynamic)await HTTP.Request(input, options, CancellationToken.None); - NUnit.Framework.Legacy.StringAssert.Contains(result.Body, "OK"); + ClassicAssert.IsTrue(result.Body.Contains(expected)); } [TestMethod] @@ -125,17 +78,12 @@ public void RequestShouldThrowExceptionIfUrlEmpty() } [TestMethod] - public void RequestShuldThrowExceptionIfOptionIsSet() + public void RequestShouldThrowExceptionIfOptionIsSet() { - const string expectedReturn = @"'FooBar'"; - - _mockHttpMessageHandler.When($"{BasePath}/endpoint") - .Respond(HttpStatusCode.InternalServerError, "application/json", expectedReturn); - var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/invalid", Headers = new Header[0], Message = "" }; @@ -148,22 +96,17 @@ public void RequestShuldThrowExceptionIfOptionIsSet() var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); - ClassicAssert.IsTrue(ex.Message.Contains("FooBar")); + ClassicAssert.IsTrue( + ex.Message.Contains("Request to 'https://httpbin.org/invalid' failed with status code 404")); } [TestMethod] public async Task RequestShouldNotThrowIfOptionIsNotSet() { - const string expectedReturn = @"'FooBar'"; - - _mockHttpMessageHandler.When($"{BasePath}/endpoint") - .Respond(HttpStatusCode.InternalServerError, "application/json", expectedReturn); - - var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/invalid", Headers = new Header[0], Message = "" }; @@ -173,54 +116,41 @@ public async Task RequestShouldNotThrowIfOptionIsNotSet() ThrowExceptionOnErrorResponse = false }; - var result = (dynamic)await HTTP.Request(input, options, CancellationToken.None); + var result = await HTTP.Request(input, options, CancellationToken.None); - ClassicAssert.IsTrue(result.Body.Contains("FooBar")); + ClassicAssert.IsTrue( + result.Body.Contains("404 Not Found")); } [TestMethod] public async Task RequestShouldAddBasicAuthHeaders() { - const string expectedReturn = @"'FooBar'"; - var input = new Input { Method = Method.Method.GET, - Url = " http://localhost:9191/endpoint", + Url = $"{BasePath}/anything", Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true, Authentication = Authentication.Basic, - Username = Guid.NewGuid().ToString(), - Password = Guid.NewGuid().ToString() + Username = "username", + Password = "password", }; - - var sentAuthValue = - "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}")); - - _mockHttpMessageHandler.Expect($"{BasePath}/endpoint").WithHeaders("Authorization", sentAuthValue) - .Respond("application/json", expectedReturn); - - var result = (dynamic)await HTTP.Request(input, options, CancellationToken.None); - - _mockHttpMessageHandler.VerifyNoOutstandingExpectation(); - ClassicAssert.IsTrue(result.Body.Contains("FooBar")); + var result = await HTTP.Request(input, options, CancellationToken.None); + ClassicAssert.IsTrue(result.Body.Contains("\"Authorization\": \"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\"")); } [TestMethod] public async Task RequestShouldAddOAuthBearerHeader() { - const string expectedReturn = @"'FooBar'"; - var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/anything", Headers = new Header[0], Message = "" }; @@ -231,23 +161,18 @@ public async Task RequestShouldAddOAuthBearerHeader() Token = "fooToken" }; - _mockHttpMessageHandler.Expect($"{BasePath}/endpoint").WithHeaders("Authorization", "Bearer fooToken") - .Respond("application/json", expectedReturn); var result = await HTTP.Request(input, options, CancellationToken.None); - _mockHttpMessageHandler.VerifyNoOutstandingExpectation(); - ClassicAssert.IsTrue(result.Body.Contains("FooBar")); + ClassicAssert.IsTrue(result.Body.Contains("\"Authorization\": \"Bearer fooToken\"")); } [TestMethod] public async Task AuthorizationHeaderShouldOverrideOption() { - const string expectedReturn = @"'FooBar'"; - var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/anything", Headers = new[] { new Header() @@ -265,12 +190,9 @@ public async Task AuthorizationHeaderShouldOverrideOption() Token = "barToken" }; - _mockHttpMessageHandler.Expect($"{BasePath}/endpoint").WithHeaders("Authorization", "Basic fooToken") - .Respond("application/json", expectedReturn); var result = await HTTP.Request(input, options, CancellationToken.None); - _mockHttpMessageHandler.VerifyNoOutstandingExpectation(); - ClassicAssert.IsTrue(result.Body.Contains("FooBar")); + ClassicAssert.IsTrue(result.Body.Contains("\"Authorization\": \"Basic fooToken\"")); } [TestMethod] @@ -280,7 +202,7 @@ public void RequestShouldAddClientCertificate() var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = BasePath, Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.JToken @@ -293,8 +215,6 @@ public void RequestShouldAddClientCertificate() CertificateThumbprint = thumbprint }; - HTTP.ClientFactory = new HttpClientFactory(); - var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); @@ -304,17 +224,10 @@ public void RequestShouldAddClientCertificate() [TestMethod] public async Task RestRequestBodyReturnShouldBeOfTypeJToken() { - dynamic dyn = new - { - Foo = "Bar" - }; - string output = JsonConvert.SerializeObject(dyn); - - var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/anything", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.JToken @@ -326,12 +239,9 @@ public async Task RestRequestBodyReturnShouldBeOfTypeJToken() Token = "fooToken" }; - _mockHttpMessageHandler.When(input.Url) - .Respond("application/json", output); - var result = await HTTP.Request(input, options, CancellationToken.None); var resultBody = (JToken)result.Body; - ClassicAssert.AreEqual(new JValue("Bar"), resultBody["Foo"]); + ClassicAssert.AreEqual(new JValue("GET"), resultBody["method"]); } [TestMethod] @@ -340,7 +250,7 @@ public async Task RestRequestShouldNotThrowIfReturnIsEmpty() var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/status/200", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.JToken @@ -352,9 +262,6 @@ public async Task RestRequestShouldNotThrowIfReturnIsEmpty() Token = "fooToken" }; - _mockHttpMessageHandler.When(input.Url) - .Respond("application/json", String.Empty); - var result = await HTTP.Request(input, options, CancellationToken.None); ClassicAssert.AreEqual(new JValue(""), result.Body); @@ -366,7 +273,7 @@ public void RestRequestShouldThrowIfReturnIsNotValidJson() var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/xml", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.JToken @@ -378,23 +285,23 @@ public void RestRequestShouldThrowIfReturnIsNotValidJson() Token = "fooToken" }; - _mockHttpMessageHandler.When(input.Url) - .Respond("application/json", "failbar"); + // _mockHttpMessageHandler.When(input.Url) + // .Respond("application/json", "failbar"); var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); - ClassicAssert.AreEqual("Unable to read response message as json: failbar", ex.Message); + Assert.That(ex.Message.Contains("Unable to read response message as json")); } [TestMethod] public async Task HttpRequestBodyReturnShouldBeOfTypeString() { - const string expectedReturn = "BAR"; + const string expectedReturn = ""; var input = new Input { Method = Method.Method.GET, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/xml", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.String @@ -404,11 +311,9 @@ public async Task HttpRequestBodyReturnShouldBeOfTypeString() ConnectionTimeoutSeconds = 60 }; - _mockHttpMessageHandler.When(input.Url) - .Respond("text/plain", expectedReturn); var result = await HTTP.Request(input, options, CancellationToken.None); - ClassicAssert.AreEqual(expectedReturn, result.Body); + Assert.That(result.Body.Contains(expectedReturn), result.Body); } [TestMethod] @@ -419,7 +324,7 @@ public async Task PatchShouldComeThrough() var input = new Input { Method = Method.Method.PATCH, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/anything", Headers = new Header[] { }, @@ -431,13 +336,9 @@ public async Task PatchShouldComeThrough() ConnectionTimeoutSeconds = 60 }; - _mockHttpMessageHandler.Expect(new HttpMethod("PATCH"), input.Url).WithContent(message) - .Respond("text/plain", "foo åäö"); - var result = await HTTP.Request(input, options, CancellationToken.None); - _mockHttpMessageHandler.VerifyNoOutstandingExpectation(); - ClassicAssert.IsTrue(result.Body.Contains("foo åäö")); + ClassicAssert.IsTrue(result.Body.Contains("\"data\": \"\\u00e5\\u00e4\\u00f6\""), result.Body); } [TestMethod] @@ -455,7 +356,7 @@ public async Task RequestShouldSetEncodingWithContentTypeCharsetIgnoringCase() var input = new Input { Method = Method.Method.POST, - Url = "http://localhost:9191/endpoint", + Url = $"{BasePath}/anything", Headers = new Header[1] { contentType @@ -468,27 +369,16 @@ public async Task RequestShouldSetEncodingWithContentTypeCharsetIgnoringCase() ConnectionTimeoutSeconds = 60 }; - _mockHttpMessageHandler.Expect(HttpMethod.Post, input.Url).WithHeaders("cONTENT-tYpE", expectedContentType) - .WithContent(requestMessage) - .Respond("text/plain", "foo åäö"); - var result = await HTTP.Request(input, options, CancellationToken.None); - _mockHttpMessageHandler.VerifyNoOutstandingExpectation(); - ClassicAssert.IsTrue(result.Body.Contains("foo åäö")); + ClassicAssert.IsTrue(result.Body.Contains("\"Content-Type\": \"text/plain; charset=iso-8859-1\"")); } [TestMethod] public async Task RequestTest_GetMethod_ShouldSendEmptyContent() { - const string expectedReturn = "OK"; - - _mockHttpMessageHandler.When($"{BasePath}/endpoint") - .WithContent(string.Empty) - .Respond("text/plain", expectedReturn); - var input = GetInputParams( - url: "http://localhost:9191/endpoint", + url: $"{BasePath}/anything", method: Method.Method.GET, message: "This should not be sent" ); @@ -499,7 +389,7 @@ public async Task RequestTest_GetMethod_ShouldSendEmptyContent() }; var result = await HTTP.Request(input, options, CancellationToken.None); - ClassicAssert.AreEqual(expectedReturn, result.Body); + Assert.That(result.Body.Contains("\"data\": \"\""), result.Body); } [TestCase(CertificateStoreLocation.CurrentUser, "current user")] @@ -507,6 +397,7 @@ public async Task RequestTest_GetMethod_ShouldSendEmptyContent() public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) { var handler = new HttpClientHandler(); + X509Certificate2[] certificates = Array.Empty(); var options = new Options { Authentication = Authentication.ClientCertificate, @@ -514,7 +405,8 @@ public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string CertificateStoreLocation = storeLocation, CertificateThumbprint = "InvalidThumbprint", }; - var ex = Assert.Throws(() => handler.SetHandlerSettingsBasedOnOptions(options)); + var ex = Assert.Throws(() => + handler.SetHandlerSettingsBasedOnOptions(options, ref certificates)); Assert.That(ex, Is.Not.Null); Assert.That(ex.Message.Contains( $"Certificate with thumbprint: 'INVALIDTHUMBPRINT' not found in {storeLocationText} cert store.")); diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/IHttpClientFactory.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/IHttpClientFactory.cs deleted file mode 100644 index e18d2f7..0000000 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/IHttpClientFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Net.Http; - -namespace Frends.HTTP.Request.Definitions; - -/// -/// Http Client Factory Interface -/// -public interface IHttpClientFactory -{ - /// - /// Create client - /// - HttpClient CreateClient(Options options); -} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs index eae9dc8..488db48 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs @@ -13,7 +13,7 @@ namespace Frends.HTTP.Request; [ExcludeFromCodeCoverage] internal static class Extensions { - internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler handler, Options options) + internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler handler, Options options, ref X509Certificate2[] certificates) { switch (options.Authentication) { @@ -35,7 +35,7 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han break; case Authentication.ClientCertificate: - handler.ClientCertificates.AddRange(GetCertificates(options)); + handler.ClientCertificates.AddRange(GetCertificates(options, ref certificates)); break; } @@ -57,10 +57,8 @@ internal static void SetDefaultRequestHeadersBasedOnOptions(this HttpClient http httpClient.Timeout = TimeSpan.FromSeconds(Convert.ToDouble(options.ConnectionTimeoutSeconds)); } - private static X509Certificate[] GetCertificates(Options options) + private static X509Certificate[] GetCertificates(Options options, ref X509Certificate2[] certificates) { - X509Certificate2[] certificates; - switch (options.ClientCertificateSource) { case CertificateSource.CertificateStore: @@ -125,40 +123,39 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, ? "current user" : "local machine"; - using (var store = new X509Store(StoreName.My, location)) - { - store.Open(OpenFlags.ReadOnly); - var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + using var store = new X509Store(StoreName.My, location); - if (signingCert.Count == 0) - { - throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); - } + store.Open(OpenFlags.ReadOnly); + var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + + if (signingCert.Count == 0) + { + throw new FileNotFoundException( + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); + } - var certificate = signingCert[0]; + var certificate = signingCert[0]; - if (!loadEntireChain) + if (!loadEntireChain) + { + return new[] { - return new[] - { - certificate - }; - } - - using var chain = new X509Chain(); - chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - chain.Build(certificate); - - // include the whole chain - var certificates = chain - .ChainElements.Cast() - .Select(c => c.Certificate) - .OrderByDescending(c => c.HasPrivateKey) - .ToArray(); - - return certificates; + certificate + }; } + + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.Build(certificate); + + // include the whole chain + var certificates = chain + .ChainElements + .Select(c => c.Certificate) + .OrderByDescending(c => c.HasPrivateKey) + .ToArray(); + + return certificates; } } diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj index fe52021..89c7df6 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj @@ -2,7 +2,7 @@ net6.0 - 1.7.0 + 1.7.1 Frends Frends Frends diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/HttpClientFactory.cs b/Frends.HTTP.Request/Frends.HTTP.Request/HttpClientFactory.cs deleted file mode 100644 index 3187fd8..0000000 --- a/Frends.HTTP.Request/Frends.HTTP.Request/HttpClientFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Net.Http; -using Frends.HTTP.Request.Definitions; - -namespace Frends.HTTP.Request; - -internal class HttpClientFactory : IHttpClientFactory -{ - public HttpClient CreateClient(Options options) - { - var handler = new HttpClientHandler(); - handler.SetHandlerSettingsBasedOnOptions(options); - return new HttpClient(handler); - } -} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs index 9462ea2..1ffb26f 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs @@ -15,22 +15,36 @@ using System.Runtime.Caching; using System.Runtime.CompilerServices; using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography.X509Certificates; [assembly: InternalsVisibleTo("Frends.HTTP.Request.Tests")] + namespace Frends.HTTP.Request; /// /// Task class. /// -public class HTTP +public static class HTTP { - internal static IHttpClientFactory ClientFactory = new HttpClientFactory(); - internal static readonly ObjectCache ClientCache = MemoryCache.Default; - private static readonly CacheItemPolicy _cachePolicy = new CacheItemPolicy() { SlidingExpiration = TimeSpan.FromHours(1) }; + private static readonly ObjectCache ClientCache = MemoryCache.Default; + + private static readonly CacheItemPolicy _cachePolicy = new() + { + SlidingExpiration = TimeSpan.FromHours(1) + }; + + private static HttpContent httpContent; + private static HttpClient httpClient; + private static HttpClientHandler httpClientHandler; + private static HttpRequestMessage httpRequestMessage; + private static HttpResponseMessage httpResponseMessage; + private static X509Certificate2[] certificates = Array.Empty(); + internal static void ClearClientCache() { var cacheKeys = ClientCache.Select(kvp => kvp.Key).ToList(); + foreach (var cacheKey in cacheKeys) { ClientCache.Remove(cacheKey); @@ -51,57 +65,79 @@ public static async Task Request( CancellationToken cancellationToken ) { - if (string.IsNullOrEmpty(input.Url)) throw new ArgumentNullException("Url can not be empty."); + try + { + if (string.IsNullOrEmpty(input.Url)) throw new ArgumentNullException("Url can not be empty."); - var httpClient = GetHttpClientForOptions(options); - var headers = GetHeaderDictionary(input.Headers, options); + httpClient = GetHttpClientForOptions(options); + var headers = GetHeaderDictionary(input.Headers, options); - using var content = GetContent(input, headers); - using var responseMessage = await GetHttpRequestResponseAsync( - httpClient, - input.Method.ToString(), - input.Url, - content, - headers, - options, - cancellationToken) - .ConfigureAwait(false); + httpContent = GetContent(input, headers); + using var responseMessage = await GetHttpRequestResponseAsync( + httpClient, + input.Method.ToString(), + input.Url, + httpContent, + headers, + options, + cancellationToken) + .ConfigureAwait(false); - dynamic response; + Result response; - switch (input.ResultMethod) - { - case ReturnFormat.String: - var hbody = responseMessage.Content != null ? await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) : null; - var hstatusCode = (int)responseMessage.StatusCode; - var hheaders = GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content?.Headers); - response = new Result(hbody, hheaders, hstatusCode); - break; - case ReturnFormat.JToken: - var rbody = TryParseRequestStringResultAsJToken(await responseMessage.Content.ReadAsStringAsync(cancellationToken) - .ConfigureAwait(false)); - var rstatusCode = (int)responseMessage.StatusCode; - var rheaders = GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content.Headers); - response = new Result(rbody, rheaders, rstatusCode); - break; - default: throw new InvalidOperationException(); - } + switch (input.ResultMethod) + { + case ReturnFormat.String: + var hbody = responseMessage.Content != null + ? await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) + : null; + var hstatusCode = (int)responseMessage.StatusCode; + var hheaders = + GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content?.Headers); + response = new Result(hbody, hheaders, hstatusCode); + + break; + case ReturnFormat.JToken: + var rbody = TryParseRequestStringResultAsJToken(await responseMessage.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false)); + var rstatusCode = (int)responseMessage.StatusCode; + var rheaders = + GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content.Headers); + response = new Result(rbody, rheaders, rstatusCode); - if (!responseMessage.IsSuccessStatusCode && options.ThrowExceptionOnErrorResponse) + break; + default: throw new InvalidOperationException(); + } + + if (!responseMessage.IsSuccessStatusCode && options.ThrowExceptionOnErrorResponse) + { + throw new WebException( + $"Request to '{input.Url}' failed with status code {(int)responseMessage.StatusCode}. Response body: {response.Body}"); + } + + return response; + } + finally { - throw new WebException( - $"Request to '{input.Url}' failed with status code {(int)responseMessage.StatusCode}. Response body: {response.Body}"); + httpContent?.Dispose(); + httpClient?.Dispose(); + httpClientHandler?.Dispose(); + httpRequestMessage?.Dispose(); + httpResponseMessage?.Dispose(); + foreach (var cert in certificates) cert?.Dispose(); } - - return response; } // Combine response- and responsecontent header to one dictionary - private static Dictionary GetResponseHeaderDictionary(HttpResponseHeaders responseMessageHeaders, HttpContentHeaders contentHeaders) + private static Dictionary GetResponseHeaderDictionary(HttpResponseHeaders responseMessageHeaders, + HttpContentHeaders contentHeaders) { var responseHeaders = responseMessageHeaders.ToDictionary(h => h.Key, h => string.Join(";", h.Value)); - var allHeaders = contentHeaders?.ToDictionary(h => h.Key, h => string.Join(";", h.Value)) ?? new Dictionary(); + var allHeaders = contentHeaders?.ToDictionary(h => h.Key, h => string.Join(";", h.Value)) ?? + new Dictionary(); responseHeaders.ToList().ForEach(x => allHeaders[x.Key] = x.Value); + return allHeaders; } @@ -109,17 +145,29 @@ private static IDictionary GetHeaderDictionary(Header[] headers, { if (!headers.Any(header => header.Name.ToLower().Equals("authorization"))) { + var authHeader = new Header + { + Name = "Authorization" + }; - var authHeader = new Header { Name = "Authorization" }; switch (options.Authentication) { case Authentication.Basic: - authHeader.Value = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}"))}"; - headers = headers.Concat(new[] { authHeader }).ToArray(); + authHeader.Value = + $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}"))}"; + headers = headers.Concat(new[] + { + authHeader + }).ToArray(); + break; case Authentication.OAuth: authHeader.Value = $"Bearer {options.Token}"; - headers = headers.Concat(new[] { authHeader }).ToArray(); + headers = headers.Concat(new[] + { + authHeader + }).ToArray(); + break; } } @@ -130,7 +178,10 @@ private static IDictionary GetHeaderDictionary(Header[] headers, private static HttpContent GetContent(Input input, IDictionary headers) { - var methodsWithBody = new[] { Method.POST, Method.PUT, Method.PATCH, Method.DELETE }; + var methodsWithBody = new[] + { + Method.POST, Method.PUT, Method.PATCH, Method.DELETE + }; if (!methodsWithBody.Contains(input.Method)) { @@ -139,9 +190,12 @@ private static HttpContent GetContent(Input input, IDictionary h if (headers.TryGetValue("content-type", out string contentTypeValue)) { - var contentTypeIsSetAndValid = MediaTypeWithQualityHeaderValue.TryParse(contentTypeValue, out var validContentType); + var contentTypeIsSetAndValid = + MediaTypeWithQualityHeaderValue.TryParse(contentTypeValue, out var validContentType); + if (contentTypeIsSetAndValid) - return new StringContent(input.Message ?? string.Empty, Encoding.GetEncoding(validContentType.CharSet ?? Encoding.UTF8.WebName)); + return new StringContent(input.Message ?? string.Empty, + Encoding.GetEncoding(validContentType.CharSet ?? Encoding.UTF8.WebName)); } return new StringContent(input.Message ?? string.Empty); @@ -163,12 +217,14 @@ private static HttpClient GetHttpClientForOptions(Options options) { var cacheKey = GetHttpClientCacheKey(options); - if (ClientCache.Get(cacheKey) is HttpClient httpClient) + if (ClientCache.Get(cacheKey) is HttpClient client) { - return httpClient; + return client; } - httpClient = ClientFactory.CreateClient(options); + httpClientHandler = new HttpClientHandler(); + httpClientHandler.SetHandlerSettingsBasedOnOptions(options, ref certificates); + httpClient = new HttpClient(httpClientHandler); httpClient.SetDefaultRequestHeadersBasedOnOptions(options); ClientCache.Add(cacheKey, httpClient, _cachePolicy); @@ -188,60 +244,59 @@ private static string GetHttpClientCacheKey(Options options) } private static async Task GetHttpRequestResponseAsync( - HttpClient httpClient, string method, string url, - HttpContent content, IDictionary headers, - Options options, CancellationToken cancellationToken) + HttpClient client, string method, string url, + HttpContent content, IDictionary headers, + Options options, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - using (var request = new HttpRequestMessage(new HttpMethod(method), new Uri(url)) - { - Content = content - }) + httpRequestMessage = new HttpRequestMessage(new HttpMethod(method), new Uri(url)); + httpRequestMessage.Content = content; + + //Clear default headers + content.Headers.Clear(); + + foreach (var header in headers) { + var requestHeaderAddedSuccessfully = + httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); - //Clear default headers - content.Headers.Clear(); - foreach (var header in headers) + if (!requestHeaderAddedSuccessfully) { - var requestHeaderAddedSuccessfully = request.Headers.TryAddWithoutValidation(header.Key, header.Value); - if (!requestHeaderAddedSuccessfully) + //Could not add to request headers try to add to content headers + // this check is probably not needed anymore as the new HttpClient does not seem fail on malformed headers + var contentHeaderAddedSuccessfully = content.Headers.TryAddWithoutValidation(header.Key, header.Value); + + if (!contentHeaderAddedSuccessfully) { - //Could not add to request headers try to add to content headers - // this check is probably not needed anymore as the new HttpClient does not seem fail on malformed headers - var contentHeaderAddedSuccessfully = content.Headers.TryAddWithoutValidation(header.Key, header.Value); - if (!contentHeaderAddedSuccessfully) - { - Trace.TraceWarning($"Could not add header {header.Key}:{header.Value}"); - } + Trace.TraceWarning($"Could not add header {header.Key}:{header.Value}"); } } + } - HttpResponseMessage response; - try - { - response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - } - catch (TaskCanceledException canceledException) + try + { + httpResponseMessage = await client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException canceledException) + { + if (cancellationToken.IsCancellationRequested) { - if (cancellationToken.IsCancellationRequested) - { - // Cancellation is from outside -> Just throw - throw; - } - - // Cancellation is from inside of the request, mostly likely a timeout - throw new Exception("HttpRequest was canceled, most likely due to a timeout.", canceledException); + // Cancellation is from outside -> Just throw + throw; } + // Cancellation is from inside of the request, mostly likely a timeout + throw new Exception("HttpRequest was canceled, most likely due to a timeout.", canceledException); + } - // this check is probably not needed anymore as the new HttpClient does not fail on invalid charsets - if (options.AllowInvalidResponseContentTypeCharSet && response.Content.Headers?.ContentType != null) - { - response.Content.Headers.ContentType.CharSet = null; - } - return response; + // this check is probably not needed anymore as the new HttpClient does not fail on invalid charsets + if (options.AllowInvalidResponseContentTypeCharSet && httpResponseMessage.Content.Headers?.ContentType != null) + { + httpResponseMessage.Content.Headers.ContentType.CharSet = null; } + + return httpResponseMessage; } } From e25c7aa509e1dc0cabe490aa576627c9bc6207fa Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Fri, 10 Apr 2026 02:47:19 +0200 Subject: [PATCH 2/4] add new option to disable HttpClient caching --- Frends.HTTP.Request/CHANGELOG.md | 67 +- .../Definitions/Options.cs | 7 + .../Frends.HTTP.Request.csproj | 2 +- .../Frends.HTTP.Request/Request.cs | 609 +++++++++--------- 4 files changed, 364 insertions(+), 321 deletions(-) diff --git a/Frends.HTTP.Request/CHANGELOG.md b/Frends.HTTP.Request/CHANGELOG.md index 8e4cb47..2848713 100644 --- a/Frends.HTTP.Request/CHANGELOG.md +++ b/Frends.HTTP.Request/CHANGELOG.md @@ -1,57 +1,88 @@ # Changelog ## [1.8.0] - 2026-03-07 + ### Fixed -- Improve handling disposable objects to avoid problems with timeouts related to HttpClient. + +- Improve the handling of disposable objects to avoid problems with timeouts related to HttpClient. + +### Added + +- Added an option "CacheHttpClient" to disable caching httpClient. (there were cached by default) ## [1.7.0] - 2026-03-03 + ### Added -- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when + using certificate authentication. ## [1.6.0] - 2026-01-27 + ### Fixed + - GET requests ignore message body content ## [1.5.0] - 2025-10-03 + ### Changed + - Changed default return format from String to JToken ## [1.4.0] - 2025-03-25 + ### Changed + - Update packages: - Newtonsoft.Json 12.0.1 -> 13.0.3 - System.DirectoryServices 7.0.0 -> 9.0.3 - System.Runtime.Caching 7.0.0 -> 9.0.3 - coverlet.collector 3.1.0 -> 6.0.4 - Microsoft.NET.Test.Sdk 16.7.0 -> 17.13.0 - MSTest.TestAdapter 2.1.2 -> 3.8.3 - MSTest.TestFramework 2.1.2 -> 3.8.3 - nunit 3.12.0 -> 4.3.2 - NUnit3TestAdapter 3.17.0 -> 5.0.0 - RichardSzalay.MockHttp 6.0.0 -> 7.0.0 - xunit.extensibility.core 2.4.2 -> 2.9.3 + Newtonsoft.Json 12.0.1 -> 13.0.3 + System.DirectoryServices 7.0.0 -> 9.0.3 + System.Runtime.Caching 7.0.0 -> 9.0.3 + coverlet.collector 3.1.0 -> 6.0.4 + Microsoft.NET.Test.Sdk 16.7.0 -> 17.13.0 + MSTest.TestAdapter 2.1.2 -> 3.8.3 + MSTest.TestFramework 2.1.2 -> 3.8.3 + nunit 3.12.0 -> 4.3.2 + NUnit3TestAdapter 3.17.0 -> 5.0.0 + RichardSzalay.MockHttp 6.0.0 -> 7.0.0 + xunit.extensibility.core 2.4.2 -> 2.9.3 ## [1.3.0] - 2024-12-30 + ### Changed -- Descriptions of ClientCertificate suboptions includes clearer information about usage in terms of different types of ClientCertificate. + +- Descriptions of ClientCertificate suboptions includes clearer information about usage in terms of different types of + ClientCertificate. ## [1.2.0] - 2024-08-19 + ### Changed -- Removed handling where only PATCH, PUT, POST and DELETE requests were allowed to have the Content-Type header and content, due to HttpClient failing if e.g., a GET request had content. HttpClient has since been updated to tolerate such requests. + +- Removed handling where only PATCH, PUT, POST and DELETE requests were allowed to have the Content-Type header and + content, due to HttpClient failing if e.g., a GET request had content. HttpClient has since been updated to tolerate + such requests. ## [1.1.2] - 2024-01-16 + ### Fixed -- Fixed misleading documentation. + +- Fixed misleading documentation. ## [1.1.1] - 2023-06-09 + ### Fixed -- Fixed issue with terminating the Task by adding cancellationToken to the method ReadAsStringAsync when JToken as ReturnFormat. + +- Fixed issue with terminating the Task by adding cancellationToken to the method ReadAsStringAsync when JToken as + ReturnFormat. ## [1.1.0] - 2023-05-08 + ### Changed -- [Breaking] Changed ResultMethod to ReturnFormat which describes the parameter better. + +- [Breaking] Changed ResultMethod to ReturnFormat which describes the parameter better. - Fixed documentation link in the main method. ## [1.0.0] - 2023-01-24 + ### Added + - Initial implementation of Frends.HTTP.Request. diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs index 12bfe0a..0da8008 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs @@ -135,4 +135,11 @@ public class Options /// true [DefaultValue(true)] public bool AutomaticCookieHandling { get; set; } = true; + + /// + /// Define to use a caching mechanism for HttpClient. + /// + /// true + [DefaultValue(true)] + public bool CacheHttpClient { get; set; } = true; } diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj index 89c7df6..edda118 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj @@ -2,7 +2,7 @@ net6.0 - 1.7.1 + 1.8.0 Frends Frends Frends diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs index 1ffb26f..9599826 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs @@ -1,302 +1,307 @@ -using Frends.HTTP.Request.Definitions; -using System.Collections.Generic; -using System; -using System.ComponentModel; -using System.Linq; -using System.Net; -using System.Net.Http.Headers; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json; -using System.Diagnostics; -using System.Net.Http; -using System.Runtime.Caching; -using System.Runtime.CompilerServices; -using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography.X509Certificates; - -[assembly: InternalsVisibleTo("Frends.HTTP.Request.Tests")] - -namespace Frends.HTTP.Request; - -/// -/// Task class. -/// -public static class HTTP -{ - private static readonly ObjectCache ClientCache = MemoryCache.Default; - - private static readonly CacheItemPolicy _cachePolicy = new() - { - SlidingExpiration = TimeSpan.FromHours(1) - }; - - private static HttpContent httpContent; - private static HttpClient httpClient; - private static HttpClientHandler httpClientHandler; - private static HttpRequestMessage httpRequestMessage; - private static HttpResponseMessage httpResponseMessage; - private static X509Certificate2[] certificates = Array.Empty(); - - - internal static void ClearClientCache() - { - var cacheKeys = ClientCache.Select(kvp => kvp.Key).ToList(); - - foreach (var cacheKey in cacheKeys) - { - ClientCache.Remove(cacheKey); - } - } - - /// - /// Frends Task for executing HTTP requests with String or JSON payload. - /// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends.HTTP.Request) - /// - /// - /// - /// - /// Object { dynamic Body, Dictionary(string, string) Headers, int StatusCode } - public static async Task Request( - [PropertyTab] Input input, - [PropertyTab] Options options, - CancellationToken cancellationToken - ) - { - try - { - if (string.IsNullOrEmpty(input.Url)) throw new ArgumentNullException("Url can not be empty."); - - httpClient = GetHttpClientForOptions(options); - var headers = GetHeaderDictionary(input.Headers, options); - - httpContent = GetContent(input, headers); - using var responseMessage = await GetHttpRequestResponseAsync( - httpClient, - input.Method.ToString(), - input.Url, - httpContent, - headers, - options, - cancellationToken) - .ConfigureAwait(false); - - Result response; - - switch (input.ResultMethod) - { - case ReturnFormat.String: - var hbody = responseMessage.Content != null - ? await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) - : null; - var hstatusCode = (int)responseMessage.StatusCode; - var hheaders = - GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content?.Headers); - response = new Result(hbody, hheaders, hstatusCode); - - break; - case ReturnFormat.JToken: - var rbody = TryParseRequestStringResultAsJToken(await responseMessage.Content - .ReadAsStringAsync(cancellationToken) - .ConfigureAwait(false)); - var rstatusCode = (int)responseMessage.StatusCode; - var rheaders = - GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content.Headers); - response = new Result(rbody, rheaders, rstatusCode); - - break; - default: throw new InvalidOperationException(); - } - - if (!responseMessage.IsSuccessStatusCode && options.ThrowExceptionOnErrorResponse) - { - throw new WebException( - $"Request to '{input.Url}' failed with status code {(int)responseMessage.StatusCode}. Response body: {response.Body}"); - } - - return response; - } - finally - { - httpContent?.Dispose(); - httpClient?.Dispose(); - httpClientHandler?.Dispose(); - httpRequestMessage?.Dispose(); - httpResponseMessage?.Dispose(); - foreach (var cert in certificates) cert?.Dispose(); - } - } - - // Combine response- and responsecontent header to one dictionary - private static Dictionary GetResponseHeaderDictionary(HttpResponseHeaders responseMessageHeaders, - HttpContentHeaders contentHeaders) - { - var responseHeaders = responseMessageHeaders.ToDictionary(h => h.Key, h => string.Join(";", h.Value)); - var allHeaders = contentHeaders?.ToDictionary(h => h.Key, h => string.Join(";", h.Value)) ?? - new Dictionary(); - responseHeaders.ToList().ForEach(x => allHeaders[x.Key] = x.Value); - - return allHeaders; - } - - private static IDictionary GetHeaderDictionary(Header[] headers, Options options) - { - if (!headers.Any(header => header.Name.ToLower().Equals("authorization"))) - { - var authHeader = new Header - { - Name = "Authorization" - }; - - switch (options.Authentication) - { - case Authentication.Basic: - authHeader.Value = - $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}"))}"; - headers = headers.Concat(new[] - { - authHeader - }).ToArray(); - - break; - case Authentication.OAuth: - authHeader.Value = $"Bearer {options.Token}"; - headers = headers.Concat(new[] - { - authHeader - }).ToArray(); - - break; - } - } - - //Ignore case for headers and key comparison - return headers.ToDictionary(key => key.Name, value => value.Value, StringComparer.InvariantCultureIgnoreCase); - } - - private static HttpContent GetContent(Input input, IDictionary headers) - { - var methodsWithBody = new[] - { - Method.POST, Method.PUT, Method.PATCH, Method.DELETE - }; - - if (!methodsWithBody.Contains(input.Method)) - { - return new StringContent(string.Empty); - } - - if (headers.TryGetValue("content-type", out string contentTypeValue)) - { - var contentTypeIsSetAndValid = - MediaTypeWithQualityHeaderValue.TryParse(contentTypeValue, out var validContentType); - - if (contentTypeIsSetAndValid) - return new StringContent(input.Message ?? string.Empty, - Encoding.GetEncoding(validContentType.CharSet ?? Encoding.UTF8.WebName)); - } - - return new StringContent(input.Message ?? string.Empty); - } - - private static object TryParseRequestStringResultAsJToken(string response) - { - try - { - return string.IsNullOrWhiteSpace(response) ? new JValue("") : JToken.Parse(response); - } - catch (JsonReaderException) - { - throw new JsonReaderException($"Unable to read response message as json: {response}"); - } - } - - private static HttpClient GetHttpClientForOptions(Options options) - { - var cacheKey = GetHttpClientCacheKey(options); - - if (ClientCache.Get(cacheKey) is HttpClient client) - { - return client; - } - - httpClientHandler = new HttpClientHandler(); - httpClientHandler.SetHandlerSettingsBasedOnOptions(options, ref certificates); - httpClient = new HttpClient(httpClientHandler); - httpClient.SetDefaultRequestHeadersBasedOnOptions(options); - - ClientCache.Add(cacheKey, httpClient, _cachePolicy); - - return httpClient; - } - - [ExcludeFromCodeCoverage] - private static string GetHttpClientCacheKey(Options options) - { - // Includes everything except for options.Token, which is used on request level, not http client level - return $"{options.Authentication}:{options.Username}:{options.Password}:{options.ClientCertificateSource}" - + $":{options.ClientCertificateFilePath}:{options.ClientCertificateInBase64}:{options.ClientCertificateKeyPhrase}" - + $":{options.CertificateThumbprint}:{options.LoadEntireChainForCertificate}:{options.ConnectionTimeoutSeconds}" - + $":{options.FollowRedirects}:{options.AllowInvalidCertificate}:{options.AllowInvalidResponseContentTypeCharSet}" - + $":{options.ThrowExceptionOnErrorResponse}:{options.AutomaticCookieHandling}"; - } - - private static async Task GetHttpRequestResponseAsync( - HttpClient client, string method, string url, - HttpContent content, IDictionary headers, - Options options, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - httpRequestMessage = new HttpRequestMessage(new HttpMethod(method), new Uri(url)); - httpRequestMessage.Content = content; - - //Clear default headers - content.Headers.Clear(); - - foreach (var header in headers) - { - var requestHeaderAddedSuccessfully = - httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); - - if (!requestHeaderAddedSuccessfully) - { - //Could not add to request headers try to add to content headers - // this check is probably not needed anymore as the new HttpClient does not seem fail on malformed headers - var contentHeaderAddedSuccessfully = content.Headers.TryAddWithoutValidation(header.Key, header.Value); - - if (!contentHeaderAddedSuccessfully) - { - Trace.TraceWarning($"Could not add header {header.Key}:{header.Value}"); - } - } - } - - try - { - httpResponseMessage = await client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); - } - catch (TaskCanceledException canceledException) - { - if (cancellationToken.IsCancellationRequested) - { - // Cancellation is from outside -> Just throw - throw; - } - - // Cancellation is from inside of the request, mostly likely a timeout - throw new Exception("HttpRequest was canceled, most likely due to a timeout.", canceledException); - } - - - // this check is probably not needed anymore as the new HttpClient does not fail on invalid charsets - if (options.AllowInvalidResponseContentTypeCharSet && httpResponseMessage.Content.Headers?.ContentType != null) - { - httpResponseMessage.Content.Headers.ContentType.CharSet = null; - } - - return httpResponseMessage; - } -} +using System.Collections.Generic; +using System; +using System.ComponentModel; +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; +using System.Diagnostics; +using System.Net.Http; +using System.Runtime.Caching; +using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography.X509Certificates; +using Frends.HTTP.Request.Definitions; + +[assembly: InternalsVisibleTo("Frends.HTTP.Request.Tests")] + +namespace Frends.HTTP.Request; + +/// +/// Task class. +/// +public static class HTTP +{ + private static readonly ObjectCache ClientCache = MemoryCache.Default; + + private static readonly CacheItemPolicy CachePolicy = new() + { + SlidingExpiration = TimeSpan.FromHours(1), + }; + + private static HttpContent httpContent; + private static HttpClient httpClient; + private static HttpClientHandler httpClientHandler; + private static HttpRequestMessage httpRequestMessage; + private static HttpResponseMessage httpResponseMessage; + private static X509Certificate2[] certificates = Array.Empty(); + + + internal static void ClearClientCache() + { + var cacheKeys = ClientCache.Select(kvp => kvp.Key).ToList(); + + foreach (var cacheKey in cacheKeys) + { + ClientCache.Remove(cacheKey); + } + } + + /// + /// Frends Task for executing HTTP requests with String or JSON payload. + /// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends.HTTP.Request) + /// + /// + /// + /// + /// Object { dynamic Body, Dictionary(string, string) Headers, int StatusCode } + public static async Task Request( + [PropertyTab] Input input, + [PropertyTab] Options options, + CancellationToken cancellationToken + ) + { + try + { + if (string.IsNullOrEmpty(input.Url)) throw new ArgumentNullException("Url can not be empty."); + + httpClient = GetHttpClientForOptions(options); + var headers = GetHeaderDictionary(input.Headers, options); + + httpContent = GetContent(input, headers); + using var responseMessage = await GetHttpRequestResponseAsync( + httpClient, + input.Method.ToString(), + input.Url, + httpContent, + headers, + options, + cancellationToken) + .ConfigureAwait(false); + + Result response; + + switch (input.ResultMethod) + { + case ReturnFormat.String: + var hbody = responseMessage.Content != null + ? await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) + : null; + var hstatusCode = (int)responseMessage.StatusCode; + var hheaders = + GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content?.Headers); + response = new Result(hbody, hheaders, hstatusCode); + + break; + case ReturnFormat.JToken: + var rbody = TryParseRequestStringResultAsJToken(await responseMessage.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false)); + var rstatusCode = (int)responseMessage.StatusCode; + var rheaders = + GetResponseHeaderDictionary(responseMessage.Headers, responseMessage.Content.Headers); + response = new Result(rbody, rheaders, rstatusCode); + + break; + default: throw new InvalidOperationException(); + } + + if (!responseMessage.IsSuccessStatusCode && options.ThrowExceptionOnErrorResponse) + { + throw new WebException( + $"Request to '{input.Url}' failed with status code {(int)responseMessage.StatusCode}. Response body: {response.Body}"); + } + + return response; + } + finally + { + httpContent?.Dispose(); + httpClient?.Dispose(); + httpClientHandler?.Dispose(); + httpRequestMessage?.Dispose(); + httpResponseMessage?.Dispose(); + foreach (var cert in certificates) cert?.Dispose(); + } + } + + // Combine response- and responsecontent header to one dictionary + private static Dictionary GetResponseHeaderDictionary(HttpResponseHeaders responseMessageHeaders, + HttpContentHeaders contentHeaders) + { + var responseHeaders = responseMessageHeaders.ToDictionary(h => h.Key, h => string.Join(";", h.Value)); + var allHeaders = contentHeaders?.ToDictionary(h => h.Key, h => string.Join(";", h.Value)) ?? + new Dictionary(); + responseHeaders.ToList().ForEach(x => allHeaders[x.Key] = x.Value); + + return allHeaders; + } + + private static IDictionary GetHeaderDictionary(Header[] headers, Options options) + { + if (!headers.Any(header => header.Name.ToLower().Equals("authorization"))) + { + var authHeader = new Header + { + Name = "Authorization" + }; + + switch (options.Authentication) + { + case Authentication.Basic: + authHeader.Value = + $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}"))}"; + headers = headers.Concat(new[] + { + authHeader + }).ToArray(); + + break; + case Authentication.OAuth: + authHeader.Value = $"Bearer {options.Token}"; + headers = headers.Concat(new[] + { + authHeader + }).ToArray(); + + break; + } + } + + //Ignore case for headers and key comparison + return headers.ToDictionary(key => key.Name, value => value.Value, StringComparer.InvariantCultureIgnoreCase); + } + + private static HttpContent GetContent(Input input, IDictionary headers) + { + var methodsWithBody = new[] + { + Method.POST, Method.PUT, Method.PATCH, Method.DELETE + }; + + if (!methodsWithBody.Contains(input.Method)) + { + return new StringContent(string.Empty); + } + + if (headers.TryGetValue("content-type", out string contentTypeValue)) + { + var contentTypeIsSetAndValid = + MediaTypeWithQualityHeaderValue.TryParse(contentTypeValue, out var validContentType); + + if (contentTypeIsSetAndValid) + return new StringContent(input.Message ?? string.Empty, + Encoding.GetEncoding(validContentType.CharSet ?? Encoding.UTF8.WebName)); + } + + return new StringContent(input.Message ?? string.Empty); + } + + private static object TryParseRequestStringResultAsJToken(string response) + { + try + { + return string.IsNullOrWhiteSpace(response) ? new JValue("") : JToken.Parse(response); + } + catch (JsonReaderException) + { + throw new JsonReaderException($"Unable to read response message as json: {response}"); + } + } + + private static HttpClient GetHttpClientForOptions(Options options) + { + string cacheKey = null; + + if (options.CacheHttpClient) + { + cacheKey = GetHttpClientCacheKey(options); + + if (ClientCache.Get(cacheKey) is HttpClient client) + { + return client; + } + } + + httpClientHandler = new HttpClientHandler(); + httpClientHandler.SetHandlerSettingsBasedOnOptions(options, ref certificates); + httpClient = new HttpClient(httpClientHandler); + httpClient.SetDefaultRequestHeadersBasedOnOptions(options); + + if (cacheKey != null) ClientCache.Add(cacheKey, httpClient, CachePolicy); + + return httpClient; + } + + [ExcludeFromCodeCoverage] + private static string GetHttpClientCacheKey(Options options) + { + // Includes everything except for options.Token, which is used on request level, not http client level + return $"{options.Authentication}:{options.Username}:{options.Password}:{options.ClientCertificateSource}" + + $":{options.ClientCertificateFilePath}:{options.ClientCertificateInBase64}:{options.ClientCertificateKeyPhrase}" + + $":{options.CertificateThumbprint}:{options.LoadEntireChainForCertificate}:{options.ConnectionTimeoutSeconds}" + + $":{options.FollowRedirects}:{options.AllowInvalidCertificate}:{options.AllowInvalidResponseContentTypeCharSet}" + + $":{options.ThrowExceptionOnErrorResponse}:{options.AutomaticCookieHandling}"; + } + + private static async Task GetHttpRequestResponseAsync( + HttpClient client, string method, string url, + HttpContent content, IDictionary headers, + Options options, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + httpRequestMessage = new HttpRequestMessage(new HttpMethod(method), new Uri(url)); + httpRequestMessage.Content = content; + + //Clear default headers + content.Headers.Clear(); + + foreach (var header in headers) + { + var requestHeaderAddedSuccessfully = + httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); + + if (!requestHeaderAddedSuccessfully) + { + //Could not add to request headers try to add to content headers + // this check is probably not needed anymore as the new HttpClient does not seem fail on malformed headers + var contentHeaderAddedSuccessfully = content.Headers.TryAddWithoutValidation(header.Key, header.Value); + + if (!contentHeaderAddedSuccessfully) + { + Trace.TraceWarning($"Could not add header {header.Key}:{header.Value}"); + } + } + } + + try + { + httpResponseMessage = await client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException canceledException) + { + if (cancellationToken.IsCancellationRequested) + { + // Cancellation is from outside -> Just throw + throw; + } + + // Cancellation is from inside of the request, mostly likely a timeout + throw new Exception("HttpRequest was canceled, most likely due to a timeout.", canceledException); + } + + + // this check is probably not needed anymore as the new HttpClient does not fail on invalid charsets + if (options.AllowInvalidResponseContentTypeCharSet && httpResponseMessage.Content.Headers?.ContentType != null) + { + httpResponseMessage.Content.Headers.ContentType.CharSet = null; + } + + return httpResponseMessage; + } +} From 98ba270c69a441e0ab1c6b9ef28f62dc73df317d Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Mon, 13 Apr 2026 13:52:46 +0200 Subject: [PATCH 3/4] fix finally block --- .../Frends.HTTP.Request/Request.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs index 9599826..b7b9efe 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs @@ -120,12 +120,15 @@ CancellationToken cancellationToken } finally { - httpContent?.Dispose(); - httpClient?.Dispose(); - httpClientHandler?.Dispose(); - httpRequestMessage?.Dispose(); - httpResponseMessage?.Dispose(); - foreach (var cert in certificates) cert?.Dispose(); + if (!options.CacheHttpClient) + { + httpContent?.Dispose(); + httpClient?.Dispose(); + httpClientHandler?.Dispose(); + httpRequestMessage?.Dispose(); + httpResponseMessage?.Dispose(); + foreach (var cert in certificates) cert?.Dispose(); + } } } From 2791057326efb29f7daef9d2d071a1834757daad Mon Sep 17 00:00:00 2001 From: Mateusz Noga-Wojtania Date: Tue, 14 Apr 2026 12:32:30 +0200 Subject: [PATCH 4/4] fix finally block --- Frends.HTTP.Request/Frends.HTTP.Request/Request.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs index b7b9efe..792f5f7 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs @@ -120,14 +120,15 @@ CancellationToken cancellationToken } finally { + httpResponseMessage?.Dispose(); + httpRequestMessage?.Dispose(); + httpContent?.Dispose(); + if (!options.CacheHttpClient) { - httpContent?.Dispose(); + foreach (var cert in certificates) cert?.Dispose(); httpClient?.Dispose(); httpClientHandler?.Dispose(); - httpRequestMessage?.Dispose(); - httpResponseMessage?.Dispose(); - foreach (var cert in certificates) cert?.Dispose(); } } }