From f668d8686faf3b9a48a4c4b91061425c1accc892 Mon Sep 17 00:00:00 2001 From: Ashenafi Date: Sat, 27 Apr 2024 11:37:54 +0300 Subject: [PATCH 1/3] Add support for http message handlers through configuration --- .../OdooClientHttpTest.cs | 100 ++++++++++++++++++ .../Configurations/OdooClientHttp.cs | 82 ++++++++++++++ .../Configurations/OdooClientHttpFactory.cs | 63 +++++++++++ PortaCapena.OdooJsonRpcClient/OdooClient.cs | 60 +++-------- 4 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 PortaCapena.OdooJsonRpcClient.Example/OdooClientHttpTest.cs create mode 100644 PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs create mode 100644 PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs diff --git a/PortaCapena.OdooJsonRpcClient.Example/OdooClientHttpTest.cs b/PortaCapena.OdooJsonRpcClient.Example/OdooClientHttpTest.cs new file mode 100644 index 0000000..97b43a3 --- /dev/null +++ b/PortaCapena.OdooJsonRpcClient.Example/OdooClientHttpTest.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using PortaCapena.OdooJsonRpcClient.Configurations; +using PortaCapena.OdooJsonRpcClient.Shared; +using Xunit; + +namespace PortaCapena.OdooJsonRpcClient.Example; + +public class OdooClientHttpTest : RequestTestBase, IDisposable +{ + // teardown + public void Dispose() + { + OdooClientHttp.ClearHttpMessageHandlers(); + InitializeHttpClient(); + } + + [Fact] + public async Task Can_call_custom_http_message_handler() + { + var customHandler = new TestMessageHandler(); + OdooClientHttp.Configure(f => + { + f.AddHttpMessageHandler(customHandler); + }); + InitializeHttpClient(); + var client = new OdooClient(TestConfig); + + await client.GetVersionAsync(); + + client.Should().NotBeNull(); + customHandler.IsCalled.Should().BeTrue(); + } + + //FIFO: First In First Out + [Fact] + public async Task Can_call_chain_of_handlers_in_FIFO_order() + { + var expectedOrderOfExecution = new List {1, 2}; + var uotExecutionOrder = new List(); + var firstHandler = new TestMessageHandler(1, uotExecutionOrder); + var secondHandler = new TestMessageHandler(2, uotExecutionOrder); + OdooClientHttp.Configure(f => + { + f.AddHttpMessageHandler(firstHandler); + f.AddHttpMessageHandler(secondHandler); + }); + + InitializeHttpClient(); + var client = new OdooClient(TestConfig); + + await client.GetVersionAsync(); + + client.Should().NotBeNull(); + uotExecutionOrder.Should().BeEquivalentTo(expectedOrderOfExecution); + } + + // making the internal httpclient private is affecting + // all the tests in the whole project. + // Since, it's only instantiated once and keeping old states. + private static void InitializeHttpClient() + { + // this internally resets the private httpclient + OdooClient.BasicAuthenticationUsernamePassword = "admin"; + } +} + +public class TestMessageHandler : DelegatingHandler +{ + private readonly List _orderOfExecution = new(); + private int Order { get; } + + public bool IsCalled { get; private set; } + + public TestMessageHandler() + { + } + + public TestMessageHandler(int order, List orderOfExecution) + { + Order = order; + _orderOfExecution = orderOfExecution; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + IsCalled = true; + _orderOfExecution.Add(Order); + Console.WriteLine($"Order: {Order}"); + var response = await base.SendAsync(request, cancellationToken); + return response; + } +} \ No newline at end of file diff --git a/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs new file mode 100644 index 0000000..9451990 --- /dev/null +++ b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net.Http; + +namespace PortaCapena.OdooJsonRpcClient.Configurations +{ + /// + /// Add custom delegating handler to the HttpClient pipeline + /// The order of the http message handler pipeline is important. + /// The execution order is FIFO (First in First out) + /// + /// Configure the http message handlers before creating an instance of OdooClient + /// + public sealed class OdooClientHttp + { + private static ConcurrentQueue HttpMessageHandlers { get; } = new ConcurrentQueue(); + private static readonly object Lock = new object(); + private static OdooClientHttp _instance; + + private OdooClientHttp() + { + } + + private static OdooClientHttp Instance + { + get + { + if (_instance != null) + { + return _instance; + } + lock (Lock) + { + return _instance ?? (_instance = new OdooClientHttp()); + } + } + } + + #region Internals + + internal static HttpMessageHandler ChainClientHandler(HttpClientHandler clientHandler) + { + if (!HttpMessageHandlers.Any()) + { + return clientHandler; + } + var lastMessageHandler = HttpMessageHandlers.Last(); + lastMessageHandler.InnerHandler = clientHandler; + return HttpMessageHandlers.First(); + } + + #endregion + + #region Public + + public static void Configure(Action configure) + { + configure(Instance); + } + + public void AddHttpMessageHandler(DelegatingHandler delegatingHandler) + { + var lastHandler = HttpMessageHandlers.LastOrDefault(); + if (lastHandler != null) + { + lastHandler.InnerHandler = delegatingHandler; + } + HttpMessageHandlers.Enqueue(delegatingHandler); + } + + public static void ClearHttpMessageHandlers() + { + while (!HttpMessageHandlers.IsEmpty) + { + HttpMessageHandlers.TryDequeue(out _); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs new file mode 100644 index 0000000..86d5d3a --- /dev/null +++ b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs @@ -0,0 +1,63 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace PortaCapena.OdooJsonRpcClient.Configurations +{ + /// + /// Constructs OdooClient HttpClient + /// + internal static class OdooClientHttpFactory + { + /// + /// Creates and configures a new HttpClient as needed when a new Odoo instance is created. + /// + internal static HttpClient CreateHttpClient() + { + return CreateHttpClient(CreateInnerHandler()); + } + + private static HttpClient CreateHttpClient(HttpMessageHandler handler) + { + var client = new HttpClient(handler); + + if (!string.IsNullOrEmpty(OdooClient.BasicAuthenticationUsernamePassword)) + { + var byteArray = Encoding.ASCII.GetBytes(OdooClient.BasicAuthenticationUsernamePassword); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); + } + + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return client; + } + + private static HttpMessageHandler CreateInnerHandler() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + ClientCertificateOptions = ClientCertificateOption.Manual, + ServerCertificateCustomValidationCallback = ServerCertificateValidation + }; + return OdooClientHttp.ChainClientHandler(handler); + } + + private static bool ServerCertificateValidation( + HttpRequestMessage httpRequestMessage, + X509Certificate2 x509Certificate2, + X509Chain x509Chain, + SslPolicyErrors sslPolicyErrors + ) + { + if (!OdooClient.ValidateServerCertificate) + { + return true; + } + return sslPolicyErrors == SslPolicyErrors.None; + } + } +} \ No newline at end of file diff --git a/PortaCapena.OdooJsonRpcClient/OdooClient.cs b/PortaCapena.OdooJsonRpcClient/OdooClient.cs index b773c76..d2b8de3 100644 --- a/PortaCapena.OdooJsonRpcClient/OdooClient.cs +++ b/PortaCapena.OdooJsonRpcClient/OdooClient.cs @@ -2,14 +2,12 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using PortaCapena.OdooJsonRpcClient.Configurations; using PortaCapena.OdooJsonRpcClient.Consts; using PortaCapena.OdooJsonRpcClient.Extensions; using PortaCapena.OdooJsonRpcClient.Models; @@ -23,12 +21,12 @@ public sealed class OdooClient private static HttpClient _client; public OdooConfig Config { get; } - [ThreadStatic] private static int? _userUid; - - /// - /// Can be set to false, if server certificate shall not be validated. - /// Useful for test systems without valid SSL certificate - /// + [ThreadStatic] private static int? _userUid; + + /// + /// Can be set to false, if server certificate shall not be validated. + /// Useful for test systems without valid SSL certificate + /// public static bool ValidateServerCertificate { get; set; } = true; private static string basicAuthenticationUsernamePassword; @@ -36,7 +34,7 @@ public sealed class OdooClient /// Username and Password for Basic Authentication (htaccess). /// Syntax: username:password /// - public static string BasicAuthenticationUsernamePassword + public static string BasicAuthenticationUsernamePassword { get => basicAuthenticationUsernamePassword; set @@ -49,43 +47,11 @@ public static string BasicAuthenticationUsernamePassword static OdooClient() { InitializeHttpClient(); - } - - private static void InitializeHttpClient() - { - var handler = new HttpClientHandler - { - AllowAutoRedirect = false, - ClientCertificateOptions = ClientCertificateOption.Manual - }; - - handler.ServerCertificateCustomValidationCallback = ServerCertificateValidation; - - _client = new HttpClient(handler); - - if (!string.IsNullOrEmpty(BasicAuthenticationUsernamePassword)) - { - var byteArray = Encoding.ASCII.GetBytes(BasicAuthenticationUsernamePassword); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray)); - } - - _client.DefaultRequestHeaders.Accept.Clear(); - _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } - private static bool ServerCertificateValidation(HttpRequestMessage httpRequestMessage, X509Certificate2 x509Certificate2, - X509Chain x509Chain, SslPolicyErrors sslPolicyErrors) + private static void InitializeHttpClient() { - if (!ValidateServerCertificate) - { - return true; - } - - if (sslPolicyErrors == SslPolicyErrors.None) - { - return true; - } - return false; + _client = OdooClientHttpFactory.CreateHttpClient(); } public OdooClient(OdooConfig config) @@ -110,8 +76,8 @@ public OdooClient(OdooConfig config) var tableName = OdooExtensions.GetOdooTableName(); var request = OdooRequestModel.SearchRead(odooConfig, userUid, tableName, query, context); return await CallAndDeserializeAsync(request, cancellationToken); - } - + } + public async Task> GetAsync(string tableName, OdooQuery query = null, OdooContext context = null, CancellationToken cancellationToken = default) { return await ExecuteWitrAccesDenideRetryAsync(userUid => GetAsync(tableName, userUid, query, SelectContext(context, Config.Context), cancellationToken)); @@ -119,7 +85,7 @@ public async Task> GetAsync(string tableName, public async Task> GetAsync(string tableName, int userUid, OdooQuery query = null, OdooContext context = null, CancellationToken cancellationToken = default) { return await GetAsync(tableName, Config, userUid, query, SelectContext(context, Config.Context), cancellationToken); - } + } public static async Task> GetAsync(string tableName, OdooConfig odooConfig, int userUid, OdooQuery query = null, OdooContext context = null, CancellationToken cancellationToken = default) { var request = OdooRequestModel.SearchRead(odooConfig, userUid, tableName, query, context); From dfbb4f799fe5b13174983fd417ad84ffca61fbd9 Mon Sep 17 00:00:00 2001 From: Ashenafi Date: Sat, 20 Jul 2024 21:43:43 +0300 Subject: [PATCH 2/3] Add readme for custom http message handler --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index 3423198..bacde56 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,52 @@ var products = await repository.Query() .FirstOrDefaultAsync(); ``` +## Custom Http Message Handler +If U want to use custom `HttpMessageHandler`, U can use `OdooClientHttp.Configure` before creating `OdooClient` instance. This could be done on your startup class. + +These custom handlers can be used for logging odoo requests and responses, adding headers or other customizations. + +When configuring the client, you can add multiple handlers. The order in which you add them is the order in which they will be executed. + +```C# +OdooClientHttp.Configure(opt => +{ + opt.AddHttpMessageHandler(new LoggingHandler()); + //you can add more custom handlers like so + //opt.AddHttpMessageHandler(new CustomHandler()); +}); + +var config = new OdooConfig( + apiUrl: "https://odoo-api-url.com", // "http://localhost:8069" + dbName: "odoo-db-name", + userName: "admin", + password: "admin" + ); + +var odooClient = new OdooClient(config); +var versionResult = await odooClient.GetVersionAsync(); + +/** Output ** +Made a request at: 7/19/2024 7:11:28PM +Got a response at: 7/19/2024 7:11:32PM +*/ + +// You can introduce ILogger to the constructor +public class LoggingHandler : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Console.WriteLine("Made a request at: {0}", DateTime.UtcNow); + var response = await base.SendAsync(request, cancellationToken); + Console.WriteLine("Got a response at: {0}", DateTime.UtcNow); + return response; + } +} +``` + ## Odoo Request and Result models examples #### Request From 035c10252546315a2011940e2acd4ccb71e7046c Mon Sep 17 00:00:00 2001 From: Ashenafi Date: Sat, 2 Nov 2024 21:26:46 +0200 Subject: [PATCH 3/3] Rename OdooClientHttp to OdooHttpClient Refactor class name and instances from OdooClientHttp to OdooHttpClient across multiple files for consistent naming. Updated related methods and tests to reflect the new naming convention while also adding thread safety and argument checks. --- ...lientHttpTest.cs => OdooHttpClientTest.cs} | 20 +++- .../Configurations/OdooClientHttp.cs | 82 ------------- .../Configurations/OdooClientHttpFactory.cs | 2 +- .../Configurations/OdooHttpClient.cs | 111 ++++++++++++++++++ README.md | 2 +- 5 files changed, 128 insertions(+), 89 deletions(-) rename PortaCapena.OdooJsonRpcClient.Example/{OdooClientHttpTest.cs => OdooHttpClientTest.cs} (87%) delete mode 100644 PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs create mode 100644 PortaCapena.OdooJsonRpcClient/Configurations/OdooHttpClient.cs diff --git a/PortaCapena.OdooJsonRpcClient.Example/OdooClientHttpTest.cs b/PortaCapena.OdooJsonRpcClient.Example/OdooHttpClientTest.cs similarity index 87% rename from PortaCapena.OdooJsonRpcClient.Example/OdooClientHttpTest.cs rename to PortaCapena.OdooJsonRpcClient.Example/OdooHttpClientTest.cs index 97b43a3..1fd9037 100644 --- a/PortaCapena.OdooJsonRpcClient.Example/OdooClientHttpTest.cs +++ b/PortaCapena.OdooJsonRpcClient.Example/OdooHttpClientTest.cs @@ -10,12 +10,22 @@ namespace PortaCapena.OdooJsonRpcClient.Example; -public class OdooClientHttpTest : RequestTestBase, IDisposable +public class OdooHttpClientTest : RequestTestBase, IDisposable { - // teardown + // Tear down public void Dispose() { - OdooClientHttp.ClearHttpMessageHandlers(); + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + OdooHttpClient.ClearHttpMessageHandlers(); InitializeHttpClient(); } @@ -23,7 +33,7 @@ public void Dispose() public async Task Can_call_custom_http_message_handler() { var customHandler = new TestMessageHandler(); - OdooClientHttp.Configure(f => + OdooHttpClient.Configure(f => { f.AddHttpMessageHandler(customHandler); }); @@ -44,7 +54,7 @@ public async Task Can_call_chain_of_handlers_in_FIFO_order() var uotExecutionOrder = new List(); var firstHandler = new TestMessageHandler(1, uotExecutionOrder); var secondHandler = new TestMessageHandler(2, uotExecutionOrder); - OdooClientHttp.Configure(f => + OdooHttpClient.Configure(f => { f.AddHttpMessageHandler(firstHandler); f.AddHttpMessageHandler(secondHandler); diff --git a/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs deleted file mode 100644 index 9451990..0000000 --- a/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttp.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using System.Net.Http; - -namespace PortaCapena.OdooJsonRpcClient.Configurations -{ - /// - /// Add custom delegating handler to the HttpClient pipeline - /// The order of the http message handler pipeline is important. - /// The execution order is FIFO (First in First out) - /// - /// Configure the http message handlers before creating an instance of OdooClient - /// - public sealed class OdooClientHttp - { - private static ConcurrentQueue HttpMessageHandlers { get; } = new ConcurrentQueue(); - private static readonly object Lock = new object(); - private static OdooClientHttp _instance; - - private OdooClientHttp() - { - } - - private static OdooClientHttp Instance - { - get - { - if (_instance != null) - { - return _instance; - } - lock (Lock) - { - return _instance ?? (_instance = new OdooClientHttp()); - } - } - } - - #region Internals - - internal static HttpMessageHandler ChainClientHandler(HttpClientHandler clientHandler) - { - if (!HttpMessageHandlers.Any()) - { - return clientHandler; - } - var lastMessageHandler = HttpMessageHandlers.Last(); - lastMessageHandler.InnerHandler = clientHandler; - return HttpMessageHandlers.First(); - } - - #endregion - - #region Public - - public static void Configure(Action configure) - { - configure(Instance); - } - - public void AddHttpMessageHandler(DelegatingHandler delegatingHandler) - { - var lastHandler = HttpMessageHandlers.LastOrDefault(); - if (lastHandler != null) - { - lastHandler.InnerHandler = delegatingHandler; - } - HttpMessageHandlers.Enqueue(delegatingHandler); - } - - public static void ClearHttpMessageHandlers() - { - while (!HttpMessageHandlers.IsEmpty) - { - HttpMessageHandlers.TryDequeue(out _); - } - } - - #endregion - } -} \ No newline at end of file diff --git a/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs index 86d5d3a..2daa46d 100644 --- a/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs +++ b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs @@ -43,7 +43,7 @@ private static HttpMessageHandler CreateInnerHandler() ClientCertificateOptions = ClientCertificateOption.Manual, ServerCertificateCustomValidationCallback = ServerCertificateValidation }; - return OdooClientHttp.ChainClientHandler(handler); + return OdooHttpClient.ChainClientHandler(handler); } private static bool ServerCertificateValidation( diff --git a/PortaCapena.OdooJsonRpcClient/Configurations/OdooHttpClient.cs b/PortaCapena.OdooJsonRpcClient/Configurations/OdooHttpClient.cs new file mode 100644 index 0000000..536a940 --- /dev/null +++ b/PortaCapena.OdooJsonRpcClient/Configurations/OdooHttpClient.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PortaCapena.OdooJsonRpcClient.Configurations +{ + /// + /// Add custom delegating handler to the HttpClient pipeline + /// The order of the http message handler pipeline is important. + /// The execution order is FIFO (First in First out) + /// + /// Configure the http message handlers before creating an instance of OdooClient + /// + public sealed class OdooHttpClient + { + private static OdooHttpClient _instance; + private static readonly object Lock = new object(); + private readonly object _handlersLock = new object(); + private readonly List _httpMessageHandlers = new List(); + + private OdooHttpClient() + { + } + + private static OdooHttpClient Instance + { + get + { + if (_instance != null) + { + return _instance; + } + + lock (Lock) + { + return _instance ?? (_instance = new OdooHttpClient()); + } + } + } + + #region Internals + + internal static HttpMessageHandler ChainClientHandler(HttpClientHandler clientHandler) + { + var instance = Instance; + lock (instance._handlersLock) + { + return BuildChain(clientHandler, instance); + } + } + + private static HttpMessageHandler BuildChain( + HttpClientHandler clientHandler, + OdooHttpClient instance + ) + { + if (instance._httpMessageHandlers.Count == 0) + { + return clientHandler; + } + HttpMessageHandler current = clientHandler; + for (var i = instance._httpMessageHandlers.Count - 1; i >= 0; i--) + { + var handler = instance._httpMessageHandlers[i]; + handler.InnerHandler = current; + current = handler; + } + + return current; + } + + #endregion + + #region Public + + public static void Configure(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + configure(Instance); + } + + public void AddHttpMessageHandler(DelegatingHandler delegatingHandler) + { + if (delegatingHandler == null) + { + throw new ArgumentNullException(nameof(delegatingHandler)); + } + + lock (_handlersLock) + { + _httpMessageHandlers.Add(delegatingHandler); + } + } + + public static void ClearHttpMessageHandlers() + { + var instance = Instance; + lock (instance._handlersLock) + { + instance._httpMessageHandlers.Clear(); + } + } + + #endregion + } + +} \ No newline at end of file diff --git a/README.md b/README.md index bacde56..7c81aba 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ These custom handlers can be used for logging odoo requests and responses, addin When configuring the client, you can add multiple handlers. The order in which you add them is the order in which they will be executed. ```C# -OdooClientHttp.Configure(opt => +OdooHttpClient.Configure(opt => { opt.AddHttpMessageHandler(new LoggingHandler()); //you can add more custom handlers like so