mirror of
https://github.com/JKorf/CryptoExchange.Net.git
synced 2026-10-04 02:11:11 +00:00
Shared API's V2 (#289)
Added V2 Shared API's infrastructure Added request coalescing Added rate limiting admission logic Fixed some rate limiting issues
This commit is contained in:
@@ -3,6 +3,7 @@ using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Filters;
|
||||
using CryptoExchange.Net.RateLimiting.Guards;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using CryptoExchange.Net.RateLimiting.Trackers;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
@@ -33,12 +34,12 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(i == requests ? triggered : !triggered);
|
||||
}
|
||||
triggered = false;
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(!triggered);
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
@@ -79,9 +80,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimiting ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -100,12 +101,12 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(i == requests ? triggered : !triggered);
|
||||
}
|
||||
triggered = false;
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(!triggered);
|
||||
}
|
||||
|
||||
@@ -123,7 +124,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
@@ -143,7 +144,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
@@ -166,9 +167,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, key1, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, key1, 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, key2, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, key2, 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -185,9 +186,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, null, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, null, 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -205,9 +206,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -222,9 +223,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host1, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host1, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host2, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host2, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -238,8 +239,8 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, ct.Token);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, ct.Token);
|
||||
Assert.That(result2.Error, Is.TypeOf<CancellationRequestedError>());
|
||||
}
|
||||
|
||||
@@ -257,9 +258,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, 1, ct.Token);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition, null, null, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, 1, ct.Token);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Null);
|
||||
@@ -279,10 +280,10 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, null, 1, RateLimitingBehaviour.Fail, null, 1, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, 1, default);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition1, null, null, null, default);
|
||||
var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, 1, default);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Not.Null);
|
||||
@@ -321,5 +322,73 @@ namespace CryptoExchange.Net.UnitTests
|
||||
// assert
|
||||
Assert.That(evnt != null, Is.EqualTo(expectLimited));
|
||||
}
|
||||
|
||||
[TestCase(RateLimitWindowType.Fixed)]
|
||||
[TestCase(RateLimitWindowType.FixedAfterFirst)]
|
||||
[TestCase(RateLimitWindowType.Sliding)]
|
||||
[TestCase(RateLimitWindowType.Decay)]
|
||||
public async Task LowerThreshold_ReservesCapacityForFullThreshold(RateLimitWindowType windowType)
|
||||
{
|
||||
var gate = new RateLimitGate("Test");
|
||||
gate.AddGuard(new RateLimitGuard(
|
||||
RateLimitGuard.PerHost,
|
||||
new LimitItemTypeFilter(RateLimitItemType.Request),
|
||||
10,
|
||||
TimeSpan.FromHours(1),
|
||||
windowType,
|
||||
decayPerTimeSpan: 1));
|
||||
|
||||
var definition = new RequestDefinition(
|
||||
"https://test.com", "/ticker", HttpMethod.Get);
|
||||
var logger = new TraceLogger();
|
||||
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var result = await gate.ProcessAsync(
|
||||
logger, i, RateLimitItemType.Request, definition, null, 1,
|
||||
RateLimitingBehaviour.Fail, null, 0.8, default);
|
||||
Assert.That(result.Success, Is.True);
|
||||
}
|
||||
|
||||
var marketData = await gate.ProcessAsync(
|
||||
logger, 9, RateLimitItemType.Request, definition, null, 1,
|
||||
RateLimitingBehaviour.Fail, null, 0.8, default);
|
||||
Assert.That(marketData.Error, Is.TypeOf<ClientRateLimitError>());
|
||||
|
||||
var order = await gate.ProcessAsync(
|
||||
logger, 10, RateLimitItemType.Request, definition, null, 2,
|
||||
RateLimitingBehaviour.Fail, null, 1.0, default);
|
||||
Assert.That(order.Success, Is.True);
|
||||
|
||||
var overHardLimit = await gate.ProcessAsync(
|
||||
logger, 11, RateLimitItemType.Request, definition, null, 1,
|
||||
RateLimitingBehaviour.Fail, null, 1.0, default);
|
||||
Assert.That(overHardLimit.Error, Is.TypeOf<ClientRateLimitError>());
|
||||
}
|
||||
|
||||
[TestCase(0.1, 10)]
|
||||
[TestCase(1, 50)]
|
||||
[TestCase(5, 250)]
|
||||
[TestCase(60, 250)]
|
||||
public void RateLimitSafetyMargin_DefaultIsProportionalAndCapped(double periodSeconds, int expectedMarginMilliseconds)
|
||||
{
|
||||
var margin = WindowTrackerHelpers.GetDefaultSafetyMargin(TimeSpan.FromSeconds(periodSeconds));
|
||||
|
||||
Assert.That(margin, Is.EqualTo(TimeSpan.FromMilliseconds(expectedMarginMilliseconds)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RateLimitGuard_ExplicitSafetyMarginIsUsed()
|
||||
{
|
||||
var guard = new RateLimitGuard(
|
||||
RateLimitGuard.PerHost,
|
||||
new LimitItemTypeFilter(RateLimitItemType.Request),
|
||||
1,
|
||||
TimeSpan.FromSeconds(1),
|
||||
RateLimitWindowType.Sliding,
|
||||
safetyMargin: TimeSpan.Zero);
|
||||
|
||||
Assert.That(guard.SafetyMargin, Is.EqualTo(TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class RequestCoalescerTests
|
||||
{
|
||||
private static readonly TimeSpan _testTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
[Test]
|
||||
public async Task ExecuteAsync_WithIdenticalRequests_ShouldExecuteRequestOnce()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var key = CreateKey();
|
||||
var requestCompletion = CreateCompletionSource<string>();
|
||||
var requestStarted = CreateCompletionSource<bool>();
|
||||
var requestCount = 0;
|
||||
|
||||
async Task<string> ExecuteRequest(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
requestStarted.TrySetResult(true);
|
||||
return await requestCompletion.Task;
|
||||
}
|
||||
|
||||
var first = coalescer.ExecuteAsync(key, ExecuteRequest, () => "canceled", CancellationToken.None);
|
||||
await requestStarted.Task.WaitAsync(_testTimeout);
|
||||
var second = coalescer.ExecuteAsync(key, ExecuteRequest, () => "canceled", CancellationToken.None);
|
||||
|
||||
Assert.That(requestCount, Is.EqualTo(1));
|
||||
|
||||
requestCompletion.SetResult("result");
|
||||
|
||||
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("result"));
|
||||
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("result"));
|
||||
Assert.That(requestCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExecuteAsync_WithDifferentKeys_ShouldExecuteEachRequest()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var firstCompletion = CreateCompletionSource<string>();
|
||||
var secondCompletion = CreateCompletionSource<string>();
|
||||
var firstStarted = CreateCompletionSource<bool>();
|
||||
var secondStarted = CreateCompletionSource<bool>();
|
||||
var requestCount = 0;
|
||||
|
||||
var first = coalescer.ExecuteAsync(
|
||||
CreateKey(parameters: "symbol=ETH"),
|
||||
async cancellationToken =>
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
firstStarted.TrySetResult(true);
|
||||
return await firstCompletion.Task;
|
||||
},
|
||||
() => "canceled",
|
||||
CancellationToken.None);
|
||||
|
||||
var second = coalescer.ExecuteAsync(
|
||||
CreateKey(parameters: "symbol=BTC"),
|
||||
async cancellationToken =>
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
secondStarted.TrySetResult(true);
|
||||
return await secondCompletion.Task;
|
||||
},
|
||||
() => "canceled",
|
||||
CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(firstStarted.Task, secondStarted.Task).WaitAsync(_testTimeout);
|
||||
Assert.That(requestCount, Is.EqualTo(2));
|
||||
|
||||
firstCompletion.SetResult("first");
|
||||
secondCompletion.SetResult("second");
|
||||
|
||||
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("first"));
|
||||
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("second"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExecuteAsync_WhenOneListenerCancels_ShouldKeepSharedRequestRunning()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var requestCompletion = CreateCompletionSource<string>();
|
||||
var requestStarted = CreateCompletionSource<CancellationToken>();
|
||||
var requestCount = 0;
|
||||
using var firstCancellationSource = new CancellationTokenSource();
|
||||
|
||||
async Task<string> ExecuteRequest(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
requestStarted.TrySetResult(cancellationToken);
|
||||
return await requestCompletion.Task;
|
||||
}
|
||||
|
||||
var first = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", firstCancellationSource.Token);
|
||||
var requestCancellationToken = await requestStarted.Task.WaitAsync(_testTimeout);
|
||||
var second = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", CancellationToken.None);
|
||||
|
||||
firstCancellationSource.Cancel();
|
||||
|
||||
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("canceled"));
|
||||
Assert.That(requestCancellationToken.IsCancellationRequested, Is.False);
|
||||
Assert.That(requestCount, Is.EqualTo(1));
|
||||
|
||||
requestCompletion.SetResult("result");
|
||||
|
||||
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("result"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExecuteAsync_WhenAllListenersCancel_ShouldCancelSharedRequest()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var requestStarted = CreateCompletionSource<bool>();
|
||||
var requestCanceled = CreateCompletionSource<bool>();
|
||||
using var firstCancellationSource = new CancellationTokenSource();
|
||||
using var secondCancellationSource = new CancellationTokenSource();
|
||||
|
||||
async Task<string> ExecuteRequest(CancellationToken cancellationToken)
|
||||
{
|
||||
using (cancellationToken.Register(() => requestCanceled.TrySetResult(true)))
|
||||
{
|
||||
requestStarted.TrySetResult(true);
|
||||
await requestCanceled.Task;
|
||||
return "request canceled";
|
||||
}
|
||||
}
|
||||
|
||||
var first = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "listener canceled", firstCancellationSource.Token);
|
||||
await requestStarted.Task.WaitAsync(_testTimeout);
|
||||
var second = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "listener canceled", secondCancellationSource.Token);
|
||||
|
||||
firstCancellationSource.Cancel();
|
||||
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("listener canceled"));
|
||||
Assert.That(requestCanceled.Task.IsCompleted, Is.False);
|
||||
|
||||
secondCancellationSource.Cancel();
|
||||
|
||||
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("listener canceled"));
|
||||
Assert.That(await requestCanceled.Task.WaitAsync(_testTimeout), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExecuteAsync_WithAlreadyCanceledListener_ShouldNotStartRequest()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var requestCount = 0;
|
||||
using var cancellationSource = new CancellationTokenSource();
|
||||
cancellationSource.Cancel();
|
||||
|
||||
var result = await coalescer.ExecuteAsync(
|
||||
CreateKey(),
|
||||
cancellationToken =>
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
return Task.FromResult("result");
|
||||
},
|
||||
() => "canceled",
|
||||
cancellationSource.Token);
|
||||
|
||||
Assert.That(result, Is.EqualTo("canceled"));
|
||||
Assert.That(requestCount, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExecuteAsync_AfterRequestCompletes_ShouldStartNewRequest()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var requestCount = 0;
|
||||
|
||||
Task<int> ExecuteRequest(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Interlocked.Increment(ref requestCount));
|
||||
|
||||
var first = await coalescer.ExecuteAsync(CreateKey(typeof(int)), ExecuteRequest, () => -1, CancellationToken.None);
|
||||
var second = await coalescer.ExecuteAsync(CreateKey(typeof(int)), ExecuteRequest, () => -1, CancellationToken.None);
|
||||
|
||||
Assert.That(first, Is.EqualTo(1));
|
||||
Assert.That(second, Is.EqualTo(2));
|
||||
Assert.That(requestCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExecuteAsync_AfterRequestFails_ShouldStartNewRequest()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var requestCount = 0;
|
||||
|
||||
Task<string> ExecuteRequest(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Increment(ref requestCount) == 1)
|
||||
return Task.FromException<string>(new InvalidOperationException("Request failed"));
|
||||
|
||||
return Task.FromResult("result");
|
||||
}
|
||||
|
||||
Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", CancellationToken.None));
|
||||
|
||||
Assert.That(
|
||||
coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", CancellationToken.None).GetAwaiter().GetResult(),
|
||||
Is.EqualTo("result"));
|
||||
Assert.That(requestCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExecuteAsync_WhenAbandonedRequestCompletes_ShouldNotRemoveReplacementRequest()
|
||||
{
|
||||
var coalescer = CreateCoalescer();
|
||||
var key = CreateKey();
|
||||
var oldRequestCompletion = CreateCompletionSource<string>();
|
||||
var oldRequestStarted = CreateCompletionSource<bool>();
|
||||
var replacementCompletion = CreateCompletionSource<string>();
|
||||
var replacementStarted = CreateCompletionSource<bool>();
|
||||
var requestCount = 0;
|
||||
using var cancellationSource = new CancellationTokenSource();
|
||||
|
||||
async Task<string> ExecuteOldRequest(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
oldRequestStarted.TrySetResult(true);
|
||||
return await oldRequestCompletion.Task;
|
||||
}
|
||||
|
||||
async Task<string> ExecuteReplacementRequest(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
replacementStarted.TrySetResult(true);
|
||||
return await replacementCompletion.Task;
|
||||
}
|
||||
|
||||
var abandonedListener = coalescer.ExecuteAsync(key, ExecuteOldRequest, () => "canceled", cancellationSource.Token);
|
||||
await oldRequestStarted.Task.WaitAsync(_testTimeout);
|
||||
var oldPhysicalRequest = GetPhysicalRequest(coalescer, key);
|
||||
|
||||
cancellationSource.Cancel();
|
||||
Assert.That(await abandonedListener.WaitAsync(_testTimeout), Is.EqualTo("canceled"));
|
||||
|
||||
var replacementListener = coalescer.ExecuteAsync(key, ExecuteReplacementRequest, () => "canceled", CancellationToken.None);
|
||||
await replacementStarted.Task.WaitAsync(_testTimeout);
|
||||
|
||||
oldRequestCompletion.SetResult("old result");
|
||||
await oldPhysicalRequest.WaitAsync(_testTimeout);
|
||||
|
||||
var joinedReplacementListener = coalescer.ExecuteAsync(key, ExecuteReplacementRequest, () => "canceled", CancellationToken.None);
|
||||
Assert.That(requestCount, Is.EqualTo(2));
|
||||
|
||||
replacementCompletion.SetResult("replacement result");
|
||||
|
||||
Assert.That(await replacementListener.WaitAsync(_testTimeout), Is.EqualTo("replacement result"));
|
||||
Assert.That(await joinedReplacementListener.WaitAsync(_testTimeout), Is.EqualTo("replacement result"));
|
||||
Assert.That(requestCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
private static RequestCoalescer CreateCoalescer()
|
||||
=> new RequestCoalescer(NullLogger.Instance);
|
||||
|
||||
private static RequestCoalescingKey CreateKey(Type? responseType = null, string? parameters = null)
|
||||
=> new RequestCoalescingKey("GET", "https://localhost/test", parameters, responseType ?? typeof(string));
|
||||
|
||||
private static TaskCompletionSource<T> CreateCompletionSource<T>()
|
||||
=> new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private static Task GetPhysicalRequest(RequestCoalescer coalescer, RequestCoalescingKey key)
|
||||
{
|
||||
var requestsField = typeof(RequestCoalescer).GetField("_requests", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
var requests = (IDictionary)requestsField.GetValue(coalescer)!;
|
||||
var entry = requests[key]!;
|
||||
var requestProperty = entry.GetType().GetProperty("Request")!;
|
||||
return ((Lazy<Task<object>>)requestProperty.GetValue(entry)!).Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
internal class SharedApiClientTests
|
||||
{
|
||||
[Test]
|
||||
public void GetCapability_ShouldUseCapabilityTradingModes()
|
||||
{
|
||||
var sharedApi = new TestSharedApi(
|
||||
[TradingMode.Spot, TradingMode.PerpetualLinear],
|
||||
[TradingMode.Spot]);
|
||||
var client = new TestSharedApiClient(sharedApi);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(client.GetCapability<ITestCapability>(TradingMode.Spot)?.Capability, Is.SameAs(sharedApi));
|
||||
Assert.That(client.GetCapability<ITestCapability>(TradingMode.PerpetualLinear), Is.Null);
|
||||
Assert.That(client.GetCapabilities<ITestCapability>(TradingMode.Spot), Has.Count.EqualTo(1));
|
||||
Assert.That(client.GetCapabilities<ITestCapability>(TradingMode.PerpetualLinear), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CapabilityTradingModes_ShouldDefaultToSharedApiTradingModes()
|
||||
{
|
||||
var sharedApi = new TestSharedApi(
|
||||
[TradingMode.Spot, TradingMode.PerpetualLinear],
|
||||
null);
|
||||
var client = new TestSharedApiClient(sharedApi);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(client.GetCapability<ITestCapability>(TradingMode.Spot)?.Capability, Is.SameAs(sharedApi));
|
||||
Assert.That(client.GetCapability<ITestCapability>(TradingMode.PerpetualLinear)?.Capability, Is.SameAs(sharedApi));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetCapabilities_WithReference_ShouldReturnMatchingCapabilities()
|
||||
{
|
||||
var sharedApi = new TestSharedApi(
|
||||
[TradingMode.Spot],
|
||||
[TradingMode.Spot]);
|
||||
var client = new TestSharedApiClient(sharedApi);
|
||||
|
||||
var result = client.GetCapabilities(
|
||||
new SharedCapabilityReference<ITestCapability>(),
|
||||
TradingMode.Spot);
|
||||
|
||||
Assert.That(result, Has.Count.EqualTo(1));
|
||||
Assert.That(result[0].Capability, Is.SameAs(sharedApi));
|
||||
Assert.That(result[0].Options, Is.SameAs(sharedApi.CapabilityOptions));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Discover_ShouldReturnAllSharedApiInformation()
|
||||
{
|
||||
var restApi = new TestRestSharedApi();
|
||||
var socketApi = new TestSocketSharedApi();
|
||||
var client = new TestDiSharedApiClient(
|
||||
restApi,
|
||||
socketApi);
|
||||
|
||||
var result = client.Discover();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.PreferredTransport, Is.EqualTo(SharedTransport.Socket));
|
||||
Assert.That(result.SharedApis, Has.Length.EqualTo(2));
|
||||
Assert.That(result.SharedApis[0].Transport, Is.EqualTo(SharedTransport.Rest));
|
||||
Assert.That(result.SharedApis[1].Transport, Is.EqualTo(SharedTransport.Socket));
|
||||
Assert.That(result.SharedApis[0].SupportedTradingModes, Is.EqualTo(new[] { TradingMode.Spot }));
|
||||
Assert.That(result.SharedApis[0].Capabilities, Has.Length.EqualTo(1));
|
||||
Assert.That(result.SharedApis[0].Authenticated, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterSharedApiClient_ShouldResolvePreferredTransport()
|
||||
{
|
||||
var restApi = new TestRestSharedApi();
|
||||
var socketApi = new TestSocketSharedApi();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton(restApi);
|
||||
services.AddSingleton(socketApi);
|
||||
|
||||
services.RegisterSharedApiClient<
|
||||
ITestSharedApiClient,
|
||||
TestDiSharedApiClient>(sharedApis => sharedApis
|
||||
.Add(client => client.Rest)
|
||||
.Add(client => client.Socket));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
provider.GetRequiredService<ITestSharedApiClient>(),
|
||||
Is.Not.Null);
|
||||
|
||||
Assert.That(
|
||||
provider.GetRequiredService<ISharedApiClientBase>(),
|
||||
Is.InstanceOf<TestDiSharedApiClient>());
|
||||
|
||||
Assert.That(
|
||||
provider.GetRequiredService<ITestCapability>(),
|
||||
Is.SameAs(socketApi));
|
||||
|
||||
Assert.That(
|
||||
provider.GetServices<ITestCapability>().ToArray(),
|
||||
Is.EqualTo(new[] { socketApi }));
|
||||
|
||||
Assert.That(
|
||||
provider.GetRequiredService<ITestRestCapability>(),
|
||||
Is.SameAs(restApi));
|
||||
|
||||
Assert.That(
|
||||
provider.GetRequiredService<ITestSocketCapability>(),
|
||||
Is.SameAs(socketApi));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CapabilityTradingModes_ShouldIntersectApiApplicableAndOverrideModes()
|
||||
{
|
||||
var sharedApi = new TestSharedApi(
|
||||
[TradingMode.Spot, TradingMode.PerpetualLinear, TradingMode.DeliveryLinear],
|
||||
[TradingMode.Spot, TradingMode.PerpetualLinear],
|
||||
[TradingMode.PerpetualLinear, TradingMode.DeliveryLinear]);
|
||||
|
||||
Assert.That(
|
||||
sharedApi.CapabilityOptions.SupportedTradingModes,
|
||||
Is.EqualTo(new[] { TradingMode.PerpetualLinear }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CapabilityOptions_ShouldApplyIntrinsicTradingModes()
|
||||
{
|
||||
var apiModes = new[]
|
||||
{
|
||||
TradingMode.Spot,
|
||||
TradingMode.PerpetualLinear,
|
||||
TradingMode.DeliveryLinear,
|
||||
TradingMode.PerpetualInverse,
|
||||
TradingMode.DeliveryInverse
|
||||
};
|
||||
var spotOptions = new PlaceSpotOrderOptions("TestExchange");
|
||||
var futuresOptions = new GetPositionsOptions("TestExchange", false);
|
||||
var perpetualOptions = new GetFundingRateHistoryOptions(
|
||||
"TestExchange",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
100,
|
||||
false);
|
||||
var unrestrictedOptions = new GetTickerOptions("TestExchange");
|
||||
|
||||
_ = new TestOptionsHost(apiModes, spotOptions);
|
||||
_ = new TestOptionsHost(apiModes, futuresOptions);
|
||||
_ = new TestOptionsHost(apiModes, perpetualOptions);
|
||||
_ = new TestOptionsHost(apiModes, unrestrictedOptions);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(spotOptions.SupportedTradingModes, Is.EqualTo(new[] { TradingMode.Spot }));
|
||||
Assert.That(futuresOptions.SupportedTradingModes, Is.EqualTo(new[]
|
||||
{
|
||||
TradingMode.PerpetualLinear,
|
||||
TradingMode.DeliveryLinear,
|
||||
TradingMode.PerpetualInverse,
|
||||
TradingMode.DeliveryInverse
|
||||
}));
|
||||
Assert.That(perpetualOptions.SupportedTradingModes, Is.EqualTo(new[]
|
||||
{
|
||||
TradingMode.PerpetualLinear,
|
||||
TradingMode.PerpetualInverse
|
||||
}));
|
||||
Assert.That(unrestrictedOptions.SupportedTradingModes, Is.EqualTo(apiModes));
|
||||
});
|
||||
}
|
||||
|
||||
private interface ITestCapability : ISharedApiCapability
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class TestApiClient : IBaseApiClient
|
||||
{
|
||||
public string Exchange => "TestExchange";
|
||||
public string BaseAddress => "https://test.invalid";
|
||||
|
||||
public string FormatSymbol(
|
||||
string baseAsset,
|
||||
string quoteAsset,
|
||||
TradingMode tradingMode,
|
||||
DateTime? deliverDate = null)
|
||||
=> $"{baseAsset}{quoteAsset}";
|
||||
|
||||
public Task<TResult> WithRateLimitAdmissionAsync<TResult>(
|
||||
RateLimitAdmission admission,
|
||||
Func<Task<TResult>> operation)
|
||||
=> operation();
|
||||
}
|
||||
|
||||
private interface ITestRestCapability : ITestCapability, ISharedRest
|
||||
{
|
||||
}
|
||||
|
||||
private interface ITestSocketCapability : ITestCapability, ISharedSocket
|
||||
{
|
||||
}
|
||||
|
||||
private interface ITestRestSharedApi : ITestRestCapability
|
||||
{
|
||||
}
|
||||
|
||||
private interface ITestSocketSharedApi : ITestSocketCapability
|
||||
{
|
||||
}
|
||||
|
||||
private interface ITestSharedApiClient : ISharedApiClientBase
|
||||
{
|
||||
ITestRestSharedApi Rest { get; }
|
||||
ITestSocketSharedApi Socket { get; }
|
||||
}
|
||||
|
||||
private sealed class TestCapabilityOptions : CapabilityOptions<SharedRequest, ITestCapability>
|
||||
{
|
||||
public override string Description => "Test capability";
|
||||
|
||||
public TestCapabilityOptions(TradingMode[]? applicableTradingModes)
|
||||
: base("TestExchange", false, "TestOperation", [], applicableTradingModes)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSharedApi : SharedApiBase, ITestCapability
|
||||
{
|
||||
public TestCapabilityOptions CapabilityOptions { get; }
|
||||
|
||||
public TestSharedApi(TradingMode[] apiTradingModes, TradingMode[]? capabilityTradingModes)
|
||||
: this(apiTradingModes, capabilityTradingModes, null)
|
||||
{
|
||||
}
|
||||
|
||||
public TestSharedApi(
|
||||
TradingMode[] apiTradingModes,
|
||||
TradingMode[]? capabilityTradingModes,
|
||||
TradingMode[]? applicableTradingModes)
|
||||
: base(
|
||||
SharedTransport.Rest,
|
||||
new TestApiClient(),
|
||||
apiTradingModes,
|
||||
() => false,
|
||||
(baseAsset, quoteAsset, tradingMode, deliverDate) => $"{baseAsset}{quoteAsset}")
|
||||
{
|
||||
var options = new TestCapabilityOptions(applicableTradingModes)
|
||||
{
|
||||
SupportedTradingModeOverrides = capabilityTradingModes
|
||||
};
|
||||
|
||||
SetCapabilities(options);
|
||||
CapabilityOptions = options;
|
||||
}
|
||||
|
||||
public override SharedClientInfo Discover() => new();
|
||||
}
|
||||
|
||||
private sealed class TestOptionsHost : SharedApiBase
|
||||
{
|
||||
public TestOptionsHost(TradingMode[] apiTradingModes, CapabilityOptions capabilityOptions)
|
||||
: base(
|
||||
SharedTransport.Rest,
|
||||
new TestApiClient(),
|
||||
apiTradingModes,
|
||||
() => false,
|
||||
(baseAsset, quoteAsset, tradingMode, deliverDate) => $"{baseAsset}{quoteAsset}")
|
||||
{
|
||||
SetCapabilities(capabilityOptions);
|
||||
}
|
||||
|
||||
public override SharedClientInfo Discover() => new();
|
||||
}
|
||||
|
||||
private abstract class TestTransportSharedApi : SharedApiBase
|
||||
{
|
||||
protected TestTransportSharedApi(SharedTransport transport)
|
||||
: base(
|
||||
transport,
|
||||
new TestApiClient(),
|
||||
[TradingMode.Spot],
|
||||
() => false,
|
||||
(baseAsset, quoteAsset, tradingMode, deliverDate) => $"{baseAsset}{quoteAsset}")
|
||||
{
|
||||
SetCapabilities(new TestCapabilityOptions([TradingMode.Spot]));
|
||||
}
|
||||
|
||||
public override SharedClientInfo Discover() => new()
|
||||
{
|
||||
Exchange = this.Exchange,
|
||||
TypeName = GetType().Name,
|
||||
SupportedTradingModes = this.SupportedTradingModes,
|
||||
Transport = this.Transport,
|
||||
Authenticated = this.Authenticated,
|
||||
Capabilities = ((ISharedApi)this).Capabilities.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestRestSharedApi : TestTransportSharedApi, ITestRestSharedApi
|
||||
{
|
||||
public TestRestSharedApi() : base(SharedTransport.Rest)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSocketSharedApi : TestTransportSharedApi, ITestSocketSharedApi
|
||||
{
|
||||
public TestSocketSharedApi() : base(SharedTransport.Socket)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestDiSharedApiClient : SharedApiClientBase, ITestSharedApiClient
|
||||
{
|
||||
public ITestRestSharedApi Rest { get; }
|
||||
public ITestSocketSharedApi Socket { get; }
|
||||
|
||||
public TestDiSharedApiClient(
|
||||
TestRestSharedApi rest,
|
||||
TestSocketSharedApi socket)
|
||||
: base(SharedTransport.Socket, rest, socket)
|
||||
{
|
||||
Rest = rest;
|
||||
Socket = socket;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSharedApiClient : SharedApiClientBase
|
||||
{
|
||||
public TestSharedApiClient(params ISharedApiCapability[] sharedApis)
|
||||
: base(SharedTransport.Rest, sharedApis)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
internal class SharedCapabilitiesTests
|
||||
{
|
||||
[Test]
|
||||
public void AllSharedApiCapabilities_ShouldBeListedInSharedCapabilities()
|
||||
{
|
||||
var markerTypes = new[]
|
||||
{
|
||||
typeof(ISharedApiCapability),
|
||||
typeof(ISharedRest),
|
||||
typeof(ISharedSocket),
|
||||
typeof(ISharedSubscription)
|
||||
};
|
||||
|
||||
var expectedCapabilities = typeof(ISharedApiCapability).Assembly
|
||||
.GetTypes()
|
||||
.Where(x => x.IsInterface
|
||||
&& typeof(ISharedApiCapability).IsAssignableFrom(x)
|
||||
&& !markerTypes.Contains(x))
|
||||
.OrderBy(x => x.FullName)
|
||||
.ToArray();
|
||||
|
||||
var listedCapabilities = GetTypeAndNestedTypes(typeof(SharedCapabilities))
|
||||
.SelectMany(x => x.GetProperties(
|
||||
BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
||||
.SelectMany(x => x.PropertyType.GetGenericArguments())
|
||||
.Where(x => x.IsInterface
|
||||
&& typeof(ISharedApiCapability).IsAssignableFrom(x))
|
||||
.ToArray();
|
||||
|
||||
var missingCapabilities = expectedCapabilities
|
||||
.Except(listedCapabilities)
|
||||
.Select(x => x.Name)
|
||||
.ToArray();
|
||||
|
||||
var unknownCapabilities = listedCapabilities
|
||||
.Except(expectedCapabilities)
|
||||
.Select(x => x.Name)
|
||||
.ToArray();
|
||||
|
||||
var duplicateCapabilities = listedCapabilities
|
||||
.GroupBy(x => x)
|
||||
.Where(x => x.Count() > 1)
|
||||
.Select(x => x.Key.Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
missingCapabilities,
|
||||
Is.Empty,
|
||||
$"Capabilities missing from SharedCapabilities: " +
|
||||
string.Join(", ", missingCapabilities));
|
||||
|
||||
Assert.That(
|
||||
unknownCapabilities,
|
||||
Is.Empty,
|
||||
$"Unknown capabilities listed in SharedCapabilities: " +
|
||||
string.Join(", ", unknownCapabilities));
|
||||
|
||||
Assert.That(
|
||||
duplicateCapabilities,
|
||||
Is.Empty,
|
||||
$"Capabilities listed multiple times in SharedCapabilities: " +
|
||||
string.Join(", ", duplicateCapabilities));
|
||||
});
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetTypeAndNestedTypes(Type type)
|
||||
{
|
||||
yield return type;
|
||||
|
||||
foreach (var nestedType in type.GetNestedTypes(BindingFlags.Public))
|
||||
{
|
||||
foreach (var result in GetTypeAndNestedTypes(nestedType))
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using NUnit.Framework;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
internal class SharedParametersTests
|
||||
{
|
||||
[Test]
|
||||
public void AllSharedAPIOptions_ShouldListAllDefaultParametersInRequestParameterRules()
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
var optionsTypes = typeof(CapabilityOptions).Assembly
|
||||
.GetTypes()
|
||||
.Where(x => x.IsClass
|
||||
&& !x.IsAbstract
|
||||
&& typeof(CapabilityOptions).IsAssignableFrom(x))
|
||||
.OrderBy(x => x.FullName)
|
||||
.ToArray();
|
||||
|
||||
foreach (var optionsType in optionsTypes)
|
||||
{
|
||||
var genericOptionsType = GetGenericOptionsType(optionsType);
|
||||
if (genericOptionsType == null)
|
||||
{
|
||||
if (optionsType == typeof(ClosePositionOptions))
|
||||
continue;
|
||||
|
||||
failures.Add($"{optionsType.Name}: unable to determine request type");
|
||||
continue;
|
||||
}
|
||||
|
||||
var requestType = genericOptionsType.GetGenericArguments()[0];
|
||||
var defaultRulesField = GetDefaultParameterRulesField(optionsType);
|
||||
if (defaultRulesField == null)
|
||||
{
|
||||
failures.Add($"{optionsType.Name}: no _defaultParameterRules field found");
|
||||
continue;
|
||||
}
|
||||
|
||||
var rules = (RequestParameterDescription[]?)defaultRulesField.GetValue(null);
|
||||
if (rules == null)
|
||||
{
|
||||
failures.Add($"{optionsType.Name}: _defaultParameterRules is null");
|
||||
continue;
|
||||
}
|
||||
|
||||
var constructorParameterNames = requestType
|
||||
.GetConstructors(BindingFlags.Instance | BindingFlags.Public)
|
||||
.SelectMany(x => x.GetParameters())
|
||||
.Where(x => !string.Equals(
|
||||
x.Name,
|
||||
"exchangeParameters",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Select(x => x.Name!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var parameterProperties = requestType
|
||||
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
||||
.Where(x => x.DeclaringType == requestType
|
||||
|| constructorParameterNames.Contains(x.Name))
|
||||
.Where(x => x.Name != nameof(SharedRequest.ExchangeParameters))
|
||||
.Select(x => x.Name)
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
|
||||
var ruleNames = rules
|
||||
.Select(x => x.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var parameterProperty in parameterProperties)
|
||||
{
|
||||
if (!ruleNames.Contains(parameterProperty))
|
||||
{
|
||||
failures.Add(
|
||||
$"{optionsType.Name}: request property " +
|
||||
$"{requestType.Name}.{parameterProperty} has no default request parameter rule");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
failures,
|
||||
Is.Empty,
|
||||
$"Missing default request parameter rules:{Environment.NewLine}" +
|
||||
string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RequiredExchangeParameter_ShouldAcceptNameOrAnyAlias()
|
||||
{
|
||||
const string exchange = "TestExchange";
|
||||
var options = new GetTickerOptions(exchange)
|
||||
{
|
||||
ExchangeParameterRules =
|
||||
[
|
||||
ExchangeParameterRule.Required(
|
||||
"Parameter",
|
||||
"Test parameter",
|
||||
1,
|
||||
"Alias1",
|
||||
"Alias2")
|
||||
]
|
||||
};
|
||||
|
||||
var missingResult = options.ValidateRequest(null, null, [TradingMode.Spot]);
|
||||
var nameResult = options.ValidateRequest(
|
||||
new ExchangeParameters(new ExchangeParameter(exchange, "Parameter", 1)),
|
||||
null,
|
||||
[TradingMode.Spot]);
|
||||
var firstAliasResult = options.ValidateRequest(
|
||||
new ExchangeParameters(new ExchangeParameter(exchange, "Alias1", 1)),
|
||||
null,
|
||||
[TradingMode.Spot]);
|
||||
var secondAliasResult = options.ValidateRequest(
|
||||
new ExchangeParameters(new ExchangeParameter(exchange, "Alias2", 1)),
|
||||
null,
|
||||
[TradingMode.Spot]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(missingResult, Is.Not.Null);
|
||||
Assert.That(nameResult, Is.Null);
|
||||
Assert.That(firstAliasResult, Is.Null);
|
||||
Assert.That(secondAliasResult, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RequiredExchangeParameterWithoutAliases_ShouldBeRequired()
|
||||
{
|
||||
const string exchange = "TestExchange";
|
||||
var options = new GetTickerOptions(exchange)
|
||||
{
|
||||
ExchangeParameterRules =
|
||||
[
|
||||
ExchangeParameterRule.Required(
|
||||
"Parameter",
|
||||
"Test parameter",
|
||||
1)
|
||||
]
|
||||
};
|
||||
|
||||
var result = options.ValidateRequest(null, null, [TradingMode.Spot]);
|
||||
|
||||
Assert.That(result, Is.Not.Null);
|
||||
}
|
||||
|
||||
private static Type? GetGenericOptionsType(Type optionsType)
|
||||
{
|
||||
for (var type = optionsType; type != null; type = type.BaseType)
|
||||
{
|
||||
if (type.IsGenericType
|
||||
&& type.GetGenericTypeDefinition() == typeof(CapabilityOptions<,>))
|
||||
return type;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FieldInfo? GetDefaultParameterRulesField(Type optionsType)
|
||||
{
|
||||
for (var type = optionsType; type != null; type = type.BaseType)
|
||||
{
|
||||
var field = type.GetField(
|
||||
"_defaultParameterRules",
|
||||
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
|
||||
if (field != null)
|
||||
return field;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user