Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/Nexus.Crypto.SDK/NexusAPIService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ public async Task<CustomResultHolder<PagedResult<GetBalanceMutation>>> GetBalanc
Dictionary<string, string> queryParams)
{
return await GetAsync<CustomResultHolder<PagedResult<GetBalanceMutation>>>(
$"balances/hotwallet/mutations{CreateUriQuery(queryParams)}",
$"balances/hotwallet/mutations{ToQueryString(queryParams)}",
ApiVersion1_2);
}

public async Task<CustomResultHolder<PagedResult<GetMail>>> GetMails(
Dictionary<string, string> queryParams)
{
return await GetAsync<CustomResultHolder<PagedResult<GetMail>>>(
$"mail{CreateUriQuery(queryParams)}",
$"mail{ToQueryString(queryParams)}",
ApiVersion1_2);
}

Expand All @@ -84,14 +84,14 @@ public async Task<CustomResultHolder<PagedResult<GetTransaction>>> GetBrokerTran
Dictionary<string, string> queryParams)
{
return await GetAsync<CustomResultHolder<PagedResult<GetTransaction>>>(
$"transaction{CreateUriQuery(queryParams)}", ApiVersion1_2);
$"transaction{ToQueryString(queryParams)}", ApiVersion1_2);
}

public async Task<CustomResultHolder<TotalsResult<TransactionTotals>>> GetBrokerTransactionTotals(
Dictionary<string, string> queryParams)
{
return await GetAsync<CustomResultHolder<TotalsResult<TransactionTotals>>>(
$"transaction/totals{CreateUriQuery(queryParams)}", ApiVersion1_2);
$"transaction/totals{ToQueryString(queryParams)}", ApiVersion1_2);
}

public async Task<CustomResultHolder<PagedResult<GetTransfer>>> GetTransfers(GetTransfersRequest? request = null)
Expand Down Expand Up @@ -150,7 +150,7 @@ public async Task<CustomResultHolder<PagedResult<CustomerBankAccountResponse>>>
string customerCode, Dictionary<string, string> queryParams)
{
return await GetAsync<CustomResultHolder<PagedResult<CustomerBankAccountResponse>>>(
$"customer/{customerCode}/bankaccounts{CreateUriQuery(queryParams)}",
$"customer/{customerCode}/bankaccounts{ToQueryString(queryParams)}",
ApiVersion1_2);
}

Expand Down
17 changes: 13 additions & 4 deletions src/Nexus.Crypto.SDK/QueryParameterHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Reflection;

namespace Nexus.Crypto.SDK;
Expand All @@ -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]))
Expand Down
60 changes: 45 additions & 15 deletions src/Nexus.Crypto.SDK/Services/BaseService.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -154,30 +156,58 @@ public async Task<TResponse> DeleteAsync<TResponse>(string endPoint, string apiV
}

/// <summary>
/// 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 ?
/// </summary>
/// <param name="queryParams"></param>
/// <param name="dict"></param>
/// <returns></returns>
public static string CreateUriQuery(Dictionary<string, string> queryParams)
public static string ToQueryString(Dictionary<string, string> dict)
{
var query = string.Empty;
var queryStrings = new List<string>();

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

/// <summary>
/// Returns a query string of the given object, starting with ?
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public static string ToQueryString(object? obj)
{
if (obj == null) return string.Empty;
Comment thread
raymens marked this conversation as resolved.

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()!)}");
Comment thread
raymens marked this conversation as resolved.

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..];
}
}
2 changes: 1 addition & 1 deletion src/Nexus.Crypto.SDK/Services/CustomerPersonService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public Task<CustomResultHolder<PersonResponse>> CreateCustomerPerson(string cust

public Task<CustomResultHolder<PagedResult<PersonResponse>>> GetCustomerPersons(string customerCode, Dictionary<string, string> queryParams)
{
var url = $"customer/{customerCode}/person" + BaseService.CreateUriQuery(queryParams);
var url = $"customer/{customerCode}/person" + BaseService.ToQueryString(queryParams);
return service.GetAsync<CustomResultHolder<PagedResult<PersonResponse>>>(
url,
BaseService.ApiVersion1_2);
Expand Down
2 changes: 1 addition & 1 deletion src/Nexus.Crypto.SDK/Services/CustomerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task<CustomResultHolder<GetCustomer>> GetCustomer(string customerCo
public async Task<CustomResultHolder<PagedResult<GetCustomer>>> GetCustomers(Dictionary<string, string> queryParams)
{
return await service.GetAsync<CustomResultHolder<PagedResult<GetCustomer>>>(
$"customer{BaseService.CreateUriQuery(queryParams)}",
$"customer{BaseService.ToQueryString(queryParams)}",
BaseService.ApiVersion1_2);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public Task<CustomResultHolder<PagedResult<DocumentStoreRecordResponse>>> Get(st
{
queryParams["customerCode"] = customerCode;

var url = DocumentStoreRecordListUrl + BaseService.CreateUriQuery(queryParams);
var url = DocumentStoreRecordListUrl + BaseService.ToQueryString(queryParams);
return service.GetAsync<CustomResultHolder<PagedResult<DocumentStoreRecordResponse>>>(url,
BaseService.ApiVersion1_2);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Nexus.Crypto.SDK/Services/DocumentStoreTypeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public Task<CustomResultHolder<DocumentStoreTypeResponse>> GetByCode(string docu

public Task<CustomResultHolder<PagedResult<DocumentStoreTypeResponse>>> Get(Dictionary<string, string> queryParams)
{
var url = DocumentTypeUrl + BaseService.CreateUriQuery(queryParams);
var url = DocumentTypeUrl + BaseService.ToQueryString(queryParams);
return service.GetAsync<CustomResultHolder<PagedResult<DocumentStoreTypeResponse>>>(url, BaseService.ApiVersion1_2);
}

Expand Down
7 changes: 3 additions & 4 deletions tests/Nexus.Crypto.SDK.Tests/NexusLabelApiSdkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
},
Expand All @@ -273,7 +272,7 @@ public async Task GetMails_Success()
});


var response = await _logicHelper.ApiService.GetMails(new System.Collections.Generic.Dictionary<string, string>
var response = await _logicHelper.ApiService.GetMails(new Dictionary<string, string>
{
{ "startDate", "2020-06-21T16:24:09Z" },
{ "endDate", "2022-06-21T16:24:09Z" },
Expand Down Expand Up @@ -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" } }
},
Expand Down
56 changes: 55 additions & 1 deletion tests/Nexus.Crypto.SDK.Tests/QueryParameterHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
{
Expand Down Expand Up @@ -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);
}
}
4 changes: 1 addition & 3 deletions tests/Nexus.Crypto.SDK.Tests/TransactionControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading