diff --git a/PortaCapena.OdooJsonRpcClient.Example/OdooHttpClientTest.cs b/PortaCapena.OdooJsonRpcClient.Example/OdooHttpClientTest.cs new file mode 100644 index 0000000..1fd9037 --- /dev/null +++ b/PortaCapena.OdooJsonRpcClient.Example/OdooHttpClientTest.cs @@ -0,0 +1,110 @@ +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 OdooHttpClientTest : RequestTestBase, IDisposable +{ + // Tear down + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + OdooHttpClient.ClearHttpMessageHandlers(); + InitializeHttpClient(); + } + + [Fact] + public async Task Can_call_custom_http_message_handler() + { + var customHandler = new TestMessageHandler(); + OdooHttpClient.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); + OdooHttpClient.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/OdooClientHttpFactory.cs b/PortaCapena.OdooJsonRpcClient/Configurations/OdooClientHttpFactory.cs new file mode 100644 index 0000000..2daa46d --- /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 OdooHttpClient.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/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/PortaCapena.OdooJsonRpcClient/OdooClient.cs b/PortaCapena.OdooJsonRpcClient/OdooClient.cs index 99ff85b..9bf487a 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 @@ -50,43 +48,11 @@ static OdooClient() { System.Net.ServicePointManager.Expect100Continue = false; 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) @@ -111,8 +77,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)); @@ -120,7 +86,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); diff --git a/README.md b/README.md index 1cf4f12..030b9c2 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,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# +OdooHttpClient.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