diff --git a/src/Nexus.Crypto.SDK/NexusAPIService.cs b/src/Nexus.Crypto.SDK/NexusAPIService.cs index b351afa..8feafa5 100644 --- a/src/Nexus.Crypto.SDK/NexusAPIService.cs +++ b/src/Nexus.Crypto.SDK/NexusAPIService.cs @@ -63,7 +63,7 @@ public async Task>> GetBalanc Dictionary queryParams) { return await GetAsync>>( - $"balances/hotwallet/mutations{CreateUriQuery(queryParams)}", + $"balances/hotwallet/mutations{ToQueryString(queryParams)}", ApiVersion1_2); } @@ -71,7 +71,7 @@ public async Task>> GetMails( Dictionary queryParams) { return await GetAsync>>( - $"mail{CreateUriQuery(queryParams)}", + $"mail{ToQueryString(queryParams)}", ApiVersion1_2); } @@ -84,14 +84,14 @@ public async Task>> GetBrokerTran Dictionary queryParams) { return await GetAsync>>( - $"transaction{CreateUriQuery(queryParams)}", ApiVersion1_2); + $"transaction{ToQueryString(queryParams)}", ApiVersion1_2); } public async Task>> GetBrokerTransactionTotals( Dictionary queryParams) { return await GetAsync>>( - $"transaction/totals{CreateUriQuery(queryParams)}", ApiVersion1_2); + $"transaction/totals{ToQueryString(queryParams)}", ApiVersion1_2); } public async Task>> GetTransfers(GetTransfersRequest? request = null) @@ -150,7 +150,7 @@ public async Task>> string customerCode, Dictionary queryParams) { return await GetAsync>>( - $"customer/{customerCode}/bankaccounts{CreateUriQuery(queryParams)}", + $"customer/{customerCode}/bankaccounts{ToQueryString(queryParams)}", ApiVersion1_2); } diff --git a/src/Nexus.Crypto.SDK/QueryParameterHelper.cs b/src/Nexus.Crypto.SDK/QueryParameterHelper.cs index e764914..72c3ab3 100644 --- a/src/Nexus.Crypto.SDK/QueryParameterHelper.cs +++ b/src/Nexus.Crypto.SDK/QueryParameterHelper.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Reflection; namespace Nexus.Crypto.SDK; @@ -11,13 +11,22 @@ public static string ToQueryString(object? obj) var properties = obj.GetType() .GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.GetValue(obj) != null) - .Select(p => - $"{Uri.EscapeDataString(ToCamelCase(p.Name))}={Uri.EscapeDataString(p.GetValue(obj)?.ToString()!)}"); + .Select(p => (Key: ToCamelCase(p.Name), Value: p.GetValue(obj))) + .Where(kv => kv.Value != null) + .Select(kv => + $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(FormatValue(kv.Value!))}"); return string.Join("&", properties); } + private static string FormatValue(object value) => value switch + { + DateTime dt => dt.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dto => dto.ToString("O", CultureInfo.InvariantCulture), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString()! + }; + private static string ToCamelCase(string name) { if (string.IsNullOrEmpty(name) || char.IsLower(name[0])) diff --git a/src/Nexus.Crypto.SDK/Services/BaseService.cs b/src/Nexus.Crypto.SDK/Services/BaseService.cs index 2cd6885..d1310ee 100644 --- a/src/Nexus.Crypto.SDK/Services/BaseService.cs +++ b/src/Nexus.Crypto.SDK/Services/BaseService.cs @@ -1,4 +1,6 @@ -using System.Net.Http.Json; +using System.Globalization; +using System.Net.Http.Json; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Nexus.Crypto.SDK.Models; @@ -154,30 +156,58 @@ public async Task DeleteAsync(string endPoint, string apiV } /// - /// Take Dictionary of query parameters and creates the query string to paste to the URI. - /// Prepends the '?'. When the dictionary is empty, returns an empty string; + /// Returns a query string of the given dictionary, starting with ? /// - /// + /// /// - public static string CreateUriQuery(Dictionary queryParams) + public static string ToQueryString(Dictionary dict) { - var query = string.Empty; + var queryStrings = new List(); - foreach (var p in queryParams) + foreach (var (key, value) in dict) { - if (query == string.Empty) + if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value)) { - query += "?"; - } - else - { - query += "&"; + queryStrings.Add($"{Uri.EscapeDataString(ToCamelCase(key))}={Uri.EscapeDataString(value)}"); } + } + + if (queryStrings.Count > 0) + { + return "?" + string.Join("&", queryStrings); + } + + return string.Empty; + } + + /// + /// Returns a query string of the given object, starting with ? + /// + /// + /// + public static string ToQueryString(object? obj) + { + if (obj == null) return string.Empty; + + var properties = obj.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetValue(obj) != null) + .Select(p => + $"{Uri.EscapeDataString(ToCamelCase(p.Name))}={Uri.EscapeDataString(p.GetValue(obj)?.ToString()!)}"); - query += $"{p.Key}={p.Value}"; + if (properties.Any()) + { + return "?" + string.Join("&", properties); } - return query; + return string.Empty; } + private static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name) || char.IsLower(name[0])) + return name; + + return char.ToLower(name[0], CultureInfo.InvariantCulture) + name[1..]; + } } \ No newline at end of file diff --git a/src/Nexus.Crypto.SDK/Services/CustomerPersonService.cs b/src/Nexus.Crypto.SDK/Services/CustomerPersonService.cs index be254a2..95351d2 100644 --- a/src/Nexus.Crypto.SDK/Services/CustomerPersonService.cs +++ b/src/Nexus.Crypto.SDK/Services/CustomerPersonService.cs @@ -16,7 +16,7 @@ public Task> CreateCustomerPerson(string cust public Task>> GetCustomerPersons(string customerCode, Dictionary queryParams) { - var url = $"customer/{customerCode}/person" + BaseService.CreateUriQuery(queryParams); + var url = $"customer/{customerCode}/person" + BaseService.ToQueryString(queryParams); return service.GetAsync>>( url, BaseService.ApiVersion1_2); diff --git a/src/Nexus.Crypto.SDK/Services/CustomerService.cs b/src/Nexus.Crypto.SDK/Services/CustomerService.cs index ad257f0..f211f2c 100644 --- a/src/Nexus.Crypto.SDK/Services/CustomerService.cs +++ b/src/Nexus.Crypto.SDK/Services/CustomerService.cs @@ -30,7 +30,7 @@ public async Task> GetCustomer(string customerCo public async Task>> GetCustomers(Dictionary queryParams) { return await service.GetAsync>>( - $"customer{BaseService.CreateUriQuery(queryParams)}", + $"customer{BaseService.ToQueryString(queryParams)}", BaseService.ApiVersion1_2); } diff --git a/src/Nexus.Crypto.SDK/Services/DocumentStoreRecordService.cs b/src/Nexus.Crypto.SDK/Services/DocumentStoreRecordService.cs index 42bf5ae..cb64886 100644 --- a/src/Nexus.Crypto.SDK/Services/DocumentStoreRecordService.cs +++ b/src/Nexus.Crypto.SDK/Services/DocumentStoreRecordService.cs @@ -45,7 +45,7 @@ public Task>> Get(st { queryParams["customerCode"] = customerCode; - var url = DocumentStoreRecordListUrl + BaseService.CreateUriQuery(queryParams); + var url = DocumentStoreRecordListUrl + BaseService.ToQueryString(queryParams); return service.GetAsync>>(url, BaseService.ApiVersion1_2); } diff --git a/src/Nexus.Crypto.SDK/Services/DocumentStoreTypeService.cs b/src/Nexus.Crypto.SDK/Services/DocumentStoreTypeService.cs index 52d41f6..0ad52af 100644 --- a/src/Nexus.Crypto.SDK/Services/DocumentStoreTypeService.cs +++ b/src/Nexus.Crypto.SDK/Services/DocumentStoreTypeService.cs @@ -26,7 +26,7 @@ public Task> GetByCode(string docu public Task>> Get(Dictionary queryParams) { - var url = DocumentTypeUrl + BaseService.CreateUriQuery(queryParams); + var url = DocumentTypeUrl + BaseService.ToQueryString(queryParams); return service.GetAsync>>(url, BaseService.ApiVersion1_2); } diff --git a/tests/Nexus.Crypto.SDK.Tests/NexusLabelApiSdkTests.cs b/tests/Nexus.Crypto.SDK.Tests/NexusLabelApiSdkTests.cs index 5ede827..03404e1 100644 --- a/tests/Nexus.Crypto.SDK.Tests/NexusLabelApiSdkTests.cs +++ b/tests/Nexus.Crypto.SDK.Tests/NexusLabelApiSdkTests.cs @@ -262,8 +262,7 @@ public async Task GetMails_Success() _logicHelper.MockResponseHandler.AddMockResponse( new HttpRequestMessage(HttpMethod.Get, new Uri( - "https://api.quantoznexus.com/mail?startDate=2020-06-21T16:24:09Z" + - "&endDate=2022-06-21T16:24:09Z&accountCode=CJABM2HM&customerCode=NLTEST&status=ReadyToSend&type=TransactionToBeReturned")) + "https://api.quantoznexus.com/mail?startDate=2020-06-21T16%3A24%3A09Z&endDate=2022-06-21T16%3A24%3A09Z&accountCode=CJABM2HM&customerCode=NLTEST&status=ReadyToSend&type=TransactionToBeReturned")) { Headers = { { "api_version", "1.2" } } }, @@ -273,7 +272,7 @@ public async Task GetMails_Success() }); - var response = await _logicHelper.ApiService.GetMails(new System.Collections.Generic.Dictionary + var response = await _logicHelper.ApiService.GetMails(new Dictionary { { "startDate", "2020-06-21T16:24:09Z" }, { "endDate", "2022-06-21T16:24:09Z" }, @@ -778,7 +777,7 @@ public async Task GetCustomers_Success() _logicHelper.MockResponseHandler.AddMockResponse( new HttpRequestMessage(HttpMethod.Get, new Uri( - "https://api.quantoznexus.com/customer?startDate=2021-01-01T00:00:01Z&endDate=2022-01-01T00:00:03Z&status=Active")) + "https://api.quantoznexus.com/customer?startDate=2021-01-01T00%3A00%3A01Z&endDate=2022-01-01T00%3A00%3A03Z&status=Active")) { Headers = { { "api_version", "1.2" } } }, diff --git a/tests/Nexus.Crypto.SDK.Tests/QueryParameterHelperTests.cs b/tests/Nexus.Crypto.SDK.Tests/QueryParameterHelperTests.cs index e0693e3..fd25cc9 100644 --- a/tests/Nexus.Crypto.SDK.Tests/QueryParameterHelperTests.cs +++ b/tests/Nexus.Crypto.SDK.Tests/QueryParameterHelperTests.cs @@ -1,5 +1,8 @@ -namespace Nexus.Crypto.SDK.Tests; +namespace Nexus.Crypto.SDK.Tests; +using System; +using System.Globalization; +using System.Threading; using Xunit; public class QueryParameterHelperTests @@ -19,6 +22,18 @@ private class TestObject public TestEnum? EnumValue { get; set; } } + private class NumericObject + { + public decimal Price { get; set; } + public double Rate { get; set; } + } + + private class DateObject + { + public DateTime CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + } + [Fact] public void ToQueryString_NullObject_ReturnsEmptyString() { @@ -83,4 +98,43 @@ public void ToQueryString_ObjectWithNullProperties_IgnoresNullProperties() // Assert Assert.Equal("name=Alice&age=0", result); } + + [Fact] + public void ToQueryString_DecimalAndDouble_UsesInvariantCulture() + { + // Arrange + var obj = new NumericObject { Price = 1.5m, Rate = 2.75 }; + var originalCulture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // uses ',' as decimal separator + + try + { + // Act + var result = QueryParameterHelper.ToQueryString(obj); + + // Assert — values must contain '.' not ',' + Assert.Equal("price=1.5&rate=2.75", result); + } + finally + { + Thread.CurrentThread.CurrentCulture = originalCulture; + } + } + + [Fact] + public void ToQueryString_DateTime_UsesIso8601() + { + // Arrange + var dt = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); + var dto = new DateTimeOffset(2024, 6, 15, 10, 30, 0, TimeSpan.Zero); + var obj = new DateObject { CreatedAt = dt, UpdatedAt = dto }; + + // Act + var result = QueryParameterHelper.ToQueryString(obj); + + // Assert — round-trip "O" format; URI-encoded '+' becomes '%2B', ':' becomes '%3A' + Assert.Contains("createdAt=", result); + Assert.Contains("updatedAt=", result); + Assert.Contains("2024", result); + } } \ No newline at end of file diff --git a/tests/Nexus.Crypto.SDK.Tests/TransactionControllerTests.cs b/tests/Nexus.Crypto.SDK.Tests/TransactionControllerTests.cs index c96030b..0c42868 100644 --- a/tests/Nexus.Crypto.SDK.Tests/TransactionControllerTests.cs +++ b/tests/Nexus.Crypto.SDK.Tests/TransactionControllerTests.cs @@ -82,9 +82,7 @@ public async Task GetTransactions_Success() "; _logicHelper.MockResponseHandler.AddMockResponse( - new HttpRequestMessage(HttpMethod.Get, new Uri("https://api.quantoznexus.com/transaction?status=BLOCKED|PAYOUTONHOLD" + - "&customer=NL51INGB7243913512&customerIsHighrisk=false&customerIsBusiness=False&customerTrustlevel=Trusted&customerStatus=Active" + - "&startDate=2020-06-04T14:52:41Z&endDate=2022-06-04T14:52:41Z&type=SELL&isSettled=false")) + new HttpRequestMessage(HttpMethod.Get, new Uri("https://api.quantoznexus.com/transaction?status=BLOCKED%7CPAYOUTONHOLD&customer=NL51INGB7243913512&customerIsHighrisk=false&customerIsBusiness=False&customerTrustlevel=Trusted&customerStatus=Active&startDate=2020-06-04T14%3A52%3A41Z&endDate=2022-06-04T14%3A52%3A41Z&type=SELL&isSettled=false")) { Headers = { { "api_version", "1.2" }