mirror of
https://github.com/JKorf/CryptoExchange.Net.git
synced 2026-08-12 08:53:01 +00:00
Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95dd050c73 | |||
| 504836924c | |||
| f176fb5db5 | |||
| 6b575be1ac | |||
| b637d5cdc4 | |||
| a46b018c50 | |||
| 562d1d76c1 | |||
| 7dcb2241c6 | |||
| 8c4cf62d9f | |||
| 6e4dbcf7b1 | |||
| 7853834286 | |||
| 9ae1263662 | |||
| ee30a6716e | |||
| a4b7b273dc | |||
| c92eeb2ec8 | |||
| 4d4b0576ee | |||
| 9add5e0adc | |||
| 93d92beea6 | |||
| a955ccbc5c | |||
| 4e2dc564dd | |||
| 93034e8af8 | |||
| cdd0bd83ab | |||
| 61d371682c | |||
| 8aae769e54 | |||
| 1b4f6926df | |||
| acca3468f3 | |||
| d6b680d42e | |||
| 24123261e5 | |||
| aab30e05f0 | |||
| 54b15b75d3 | |||
| d715c7df59 | |||
| e1d33f252f | |||
| b2ba9e251f | |||
| 2f9f97f4e8 | |||
| 875696a73a | |||
| 90c61715d0 | |||
| 12ca45050f | |||
| eaf092a334 | |||
| 33c0fb26a7 | |||
| dabbb5c868 | |||
| 204bda8622 | |||
| 78e3523a4f | |||
| 89a73747b0 | |||
| 02b70398b3 | |||
| 73fcb47b17 | |||
| d41ca3459e | |||
| bea2b2bd7b | |||
| b29cdc41f3 | |||
| 0ce2e778f4 | |||
| 36c2411d46 | |||
| 6d3e72745a | |||
| 51c74baa26 | |||
| 419e01d009 | |||
| 297eee0e1f | |||
| 6a9231b1e3 | |||
| e40f2a15b6 | |||
| b94085a27a | |||
| 5e083811df | |||
| 7dcf5cd6ea | |||
| 1471a4733f | |||
| f39d9f7cfb | |||
| 9fab8faa45 | |||
| 226f175343 | |||
| 813bd9f5a1 | |||
| c8d2b4f09d | |||
| 6560b82a3e | |||
| e151af8f37 | |||
| bdf7a07c6f | |||
| a8ffe90bf2 | |||
| 3372b9eb44 | |||
| df25221960 | |||
| 7c67a014f5 | |||
| 65291b3195 | |||
| ece6a9d27d | |||
| 1ed29b0474 | |||
| be2eb01353 | |||
| 39cd66596d | |||
| 63f51811a9 | |||
| abda065237 | |||
| 759c8b9a58 | |||
| 8e18482781 | |||
| 87dd2d9d40 | |||
| f2b0cb2f0d | |||
| de0a954a91 | |||
| 4a79ce22ec | |||
| 40d480e1fc | |||
| 74e5cf6fc9 | |||
| 2fd3912795 | |||
| ec44307a0c | |||
| ba55705385 | |||
| 2c63a83117 | |||
| 71b1e5e906 | |||
| eaeba6f27e | |||
| 913bdaa855 | |||
| 5aa5790d0a | |||
| a8321e083e | |||
| ce3fa5f186 | |||
| fff70a9c65 | |||
| cff33bb5ac | |||
| 21c8133292 | |||
| 76772e91ba | |||
| 218e0260ce | |||
| 96b3904266 | |||
| bc8faf9822 | |||
| 90c1b89ceb | |||
| 21206ffb25 | |||
| 5942423bfb | |||
| dc4abc42a7 | |||
| c71a81e686 | |||
| 550c0eabf1 | |||
| 28a2a0c7fd | |||
| 7dd1cd5bbd | |||
| a7ff4416bd | |||
| 669d1f7c9e | |||
| 34ee2d3690 | |||
| 005fb7875d | |||
| fa9300ce97 | |||
| fc2d3fc2d2 | |||
| 187ca6a4ef | |||
| 3b2a85d210 | |||
| c512bee825 | |||
| 0943b052b9 | |||
| a896fffdb3 | |||
| 177daf903b | |||
| aa1ebdc4ed | |||
| 38058c4a70 | |||
| a7eb483479 | |||
| c76931a3b4 | |||
| b90b7e9e0c | |||
| beda53d36d | |||
| 0668f669c1 | |||
| 64250e13db | |||
| 451d38d5e7 | |||
| e11e437bbb | |||
| b8a1ad798d | |||
| 4a851c44f2 | |||
| d079796020 | |||
| f125bc88b0 | |||
| f3d535f286 | |||
| 6e8c6feec2 | |||
| 84b0444caf | |||
| 4be986ebe7 | |||
| 21872f818a | |||
| 4e7c45d758 | |||
| 0e55e5f065 | |||
| b1c5cf318a | |||
| 9d94a24862 | |||
| 6cf9684f55 | |||
| 8043a48c49 | |||
| 7d657dd533 | |||
| 1bfdec1484 | |||
| 8d5b6a53f3 | |||
| ad97102e7c | |||
| d8dc121386 | |||
| 10c3868c00 | |||
| 411ba00a82 | |||
| c1b2b62dbc | |||
| 3960cab7a7 | |||
| 34e9447e55 | |||
| 9d3295acc7 | |||
| 995cd3d84c | |||
| 919cdf0075 | |||
| d181c9cfc1 | |||
| 5943142c44 | |||
| dbc430e838 | |||
| 7413d03d31 | |||
| dd60067684 | |||
| 04e4ddf525 | |||
| 99bf6d7c75 | |||
| 99a203933c | |||
| b43d2a2040 | |||
| ba9c406def | |||
| f5f4d50cc9 | |||
| f87506b490 | |||
| f6f9a53ce5 | |||
| 61130ef54e | |||
| e8bcbd59be | |||
| d433ff7475 | |||
| 71957037d0 | |||
| bcdcdbbd4e | |||
| 1ece13f5bc | |||
| da70ba6ec7 | |||
| a832f0e4d4 | |||
| 6ab4d005a0 | |||
| 18dc935038 | |||
| bb3a534f75 | |||
| 649ba370c6 | |||
| 51732c5ce6 | |||
| 0ba7b46680 | |||
| 94dfbb7b9e | |||
| b8b7512b35 | |||
| aba6b773ce | |||
| d88fb0d356 | |||
| b8c6d55156 | |||
| d9a5481db2 | |||
| 6a8bb42c0e | |||
| 2445f001ab | |||
| c84fa9ac32 | |||
| d44a11c44e | |||
| b215cccda4 | |||
| 3eda488361 | |||
| 993a44de35 | |||
| 99465f99a1 | |||
| d42de1fe90 | |||
| d0284c62c0 | |||
| d92f3b7904 | |||
| 3e1b5ada69 | |||
| 6156fb8154 | |||
| f2753aed1e | |||
| e33d826381 | |||
| 364aa4d324 | |||
| 455b332757 | |||
| 876b895645 | |||
| daf7ed9fe6 | |||
| 3e365f83c9 | |||
| 40977ebdbe | |||
| 4a9058fc1c | |||
| dab9a21608 | |||
| a89c222399 | |||
| 1e356d2a45 |
@@ -0,0 +1,71 @@
|
||||
---
|
||||
description: Conventions for cross-exchange code using CryptoExchange.Net SharedApis abstractions. Apply when generating C# code that interacts with multiple cryptocurrency exchanges through a unified interface.
|
||||
globs:
|
||||
- "**/*.cs"
|
||||
- "**/*.csproj"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# CryptoExchange.Net Conventions
|
||||
|
||||
This codebase uses **CryptoExchange.Net** abstractions for multi-exchange access. Each exchange has its own library (Binance.Net, Bybit.Net, OKX.Net, ...). Use `CryptoExchange.Net.SharedApis` for code that should work across exchanges.
|
||||
|
||||
## Multi-exchange pattern
|
||||
|
||||
```csharp
|
||||
using Binance.Net.Clients;
|
||||
using OKX.Net.Clients;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
|
||||
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
|
||||
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
|
||||
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
// ticker.Data.LastPrice — same model regardless of exchange
|
||||
```
|
||||
|
||||
## Symbol normalization
|
||||
|
||||
`SharedSymbol(TradingMode.Spot, "BTC", "USDT")` is portable. Each library translates to its native format internally. Don't pass raw strings like `"BTCUSDT"` to shared methods.
|
||||
|
||||
## Result pattern
|
||||
|
||||
Same `WebCallResult<T>` / `CallResult<T>` everywhere. Always check `.Success`. `.Exchange` property identifies which exchange responded — useful for logging.
|
||||
|
||||
## Available shared interfaces
|
||||
|
||||
- REST tickers/symbols/orderbook/klines/trades, orders (spot/futures, regular/trigger/TP-SL), balances, positions, fees, deposits/withdrawals, transfers
|
||||
- WebSocket tickers, book tickers, order book, trades, klines, user data
|
||||
|
||||
Each exchange documents which it implements. Not every exchange supports every operation.
|
||||
|
||||
## Multi-exchange aggregation
|
||||
|
||||
Run requests across exchanges concurrently via `Task.WhenAll` — the library is async-safe and concurrent requests are the norm.
|
||||
|
||||
```csharp
|
||||
var clients = new ISpotTickerRestClient[] { binance, okx, bybit };
|
||||
var tasks = clients.Select(c => c.GetSpotTickerAsync(new GetTickerRequest(symbol)));
|
||||
var results = await Task.WhenAll(tasks);
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
- ❌ Never install `CryptoExchange.Net` alone and expect to call exchanges — it's a base library; you need exchange-specific packages
|
||||
- ❌ Never mix exchange-specific models in cross-exchange code (use `SharedSymbol`, `SharedSpotTicker`, etc.)
|
||||
- ❌ Never use `.Result` / `.Wait()` — async-only
|
||||
- ❌ Never iterate sequentially when concurrency is fine — `Task.WhenAll` is your friend
|
||||
- ❌ Never instantiate clients per-request — reuse via DI
|
||||
- ✅ Always use `.SharedClient` for cross-exchange code
|
||||
- ✅ Always check `.Success` before reading `.Data`
|
||||
- ✅ Always log with `.Exchange` so multi-exchange logs are decipherable
|
||||
- ✅ Always handle "not supported on this exchange" errors gracefully
|
||||
|
||||
## Reference
|
||||
|
||||
- `AGENTS.md` in repo root has fuller examples
|
||||
- `llms.txt` for AI context
|
||||
- `Examples/ai-friendly/` for compilable examples
|
||||
- For single-exchange code, see that exchange's library (e.g., Binance.Net `AGENTS.md`)
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copilot Instructions for CryptoExchange.Net
|
||||
|
||||
This repository is **CryptoExchange.Net** — the base library powering 28+ cryptocurrency exchange wrappers in C#/.NET (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.).
|
||||
|
||||
When generating code in this ecosystem, follow these conventions:
|
||||
|
||||
## You don't install CryptoExchange.Net directly
|
||||
|
||||
Install the exchange-specific library you need (`Binance.Net`, `JK.OKX.Net`, `Bybit.Net`, ...) or `CryptoClients.Net` for the bundle. CryptoExchange.Net is pulled in as a dependency.
|
||||
|
||||
## Multi-exchange code uses SharedApis
|
||||
|
||||
For code that must work against multiple exchanges, use `CryptoExchange.Net.SharedApis` interfaces accessed via `.SharedClient` properties on each exchange's API surface:
|
||||
|
||||
```csharp
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
|
||||
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
|
||||
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
|
||||
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
```
|
||||
|
||||
Same code works on every exchange that implements the interface. Use `Task.WhenAll` for concurrent multi-exchange calls.
|
||||
|
||||
## Single-exchange code uses the exchange's own client
|
||||
|
||||
For Binance-only code, use `BinanceRestClient` directly (see Binance.Net repo `AGENTS.md`). SharedApis is for portability — use it when you need that.
|
||||
|
||||
## Result pattern
|
||||
|
||||
Every method returns `WebCallResult<T>` or `CallResult<T>`. Check `.Success` before `.Data`. `.Error` has structured info. `.Exchange` on shared clients identifies which exchange responded.
|
||||
|
||||
## Available shared interfaces
|
||||
|
||||
REST: tickers, symbols, orderbook, klines, trades, orders (spot/futures, trigger, TP-SL), balances, positions, fees, deposits/withdrawals, transfers.
|
||||
WebSocket: tickers, book tickers, orderbook, trades, klines, user data.
|
||||
|
||||
Each exchange library implements a subset. Check exchange docs for support matrix.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Installing `CryptoExchange.Net` alone and trying to call exchange APIs (need exchange-specific packages)
|
||||
- Mixing exchange-native models in cross-exchange code (use Shared* types)
|
||||
- Synchronous `.Result` / `.Wait()` (use `await`)
|
||||
- Instantiating clients per-request (use DI, reuse instances)
|
||||
- Sequential per-exchange calls when parallel is fine (`Task.WhenAll`)
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed patterns see `AGENTS.md` and `llms.txt` in repo root, `examples/ai-friendly/` for compilable examples.
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: cryptoexchange-net
|
||||
description: Use CryptoExchange.Net abstractions when generating C#/.NET code that needs to work across MULTIPLE cryptocurrency exchanges (Binance + Bybit + OKX + Kraken + Coinbase + ...) — for arbitrage, best-execution routing, multi-exchange portfolio dashboards, exchange-agnostic trading bots, or comparison tools. Triggers on requests mentioning multi-exchange, cross-exchange, arbitrage, exchange-agnostic, or unified crypto API in C#. Also triggers when the user is implementing a new exchange library following the CryptoExchange.Net pattern.
|
||||
---
|
||||
|
||||
# CryptoExchange.Net Skill
|
||||
|
||||
## When to use
|
||||
|
||||
CryptoExchange.Net is the **base library** powering 28+ exchange-specific libraries (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.). You don't install it directly — you install the exchange libraries, which depend on it.
|
||||
|
||||
**Three usage modes:**
|
||||
|
||||
1. **You target ONE exchange** → use that exchange's library directly (e.g., Binance.Net), see its CLAUDE.md.
|
||||
2. **You target MULTIPLE exchanges** → install each library you need + use `CryptoExchange.Net.SharedApis` interfaces — write code once, runs against any exchange. **This is the main use case for this skill.**
|
||||
3. **You want ALL exchanges in one package** → install `CryptoClients.Net`, get `ExchangeRestClient` and `ExchangeSocketClient` with everything bundled.
|
||||
|
||||
## Installation
|
||||
|
||||
For a multi-exchange project:
|
||||
|
||||
```bash
|
||||
dotnet add package Binance.Net
|
||||
dotnet add package JK.OKX.Net
|
||||
dotnet add package Bybit.Net
|
||||
# ... etc
|
||||
```
|
||||
|
||||
Or the bundle:
|
||||
|
||||
```bash
|
||||
dotnet add package CryptoClients.Net
|
||||
```
|
||||
|
||||
## Core Pattern: Shared Interfaces
|
||||
|
||||
Every exchange library exposes `.SharedClient` properties on its API surfaces. These implement the same interfaces from `CryptoExchange.Net.SharedApis`.
|
||||
|
||||
```csharp
|
||||
using Binance.Net.Clients;
|
||||
using OKX.Net.Clients;
|
||||
using Bybit.Net.Clients;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
|
||||
// All three implement ISpotTickerRestClient
|
||||
ISpotTickerRestClient binance = new BinanceRestClient().SpotApi.SharedClient;
|
||||
ISpotTickerRestClient okx = new OKXRestClient().UnifiedApi.SharedClient;
|
||||
ISpotTickerRestClient bybit = new BybitRestClient().V5Api.SharedClient;
|
||||
|
||||
// Single agnostic call — works against any of them
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var ticker = await binance.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
// ticker.Data.LastPrice, ticker.Data.HighPrice, etc. — same model regardless of exchange
|
||||
```
|
||||
|
||||
## Core Pattern: SharedSymbol
|
||||
|
||||
Different exchanges format symbols differently — Binance uses `BTCUSDT`, OKX uses `BTC-USDT`, others may have other formats. `SharedSymbol` normalizes this:
|
||||
|
||||
```csharp
|
||||
var btcusdt = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
// Each exchange library translates SharedSymbol → its native format internally.
|
||||
|
||||
// For futures:
|
||||
var btcusdtPerp = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
```
|
||||
|
||||
For exchanges that use exotic asset names, see the AssetAliases configuration.
|
||||
|
||||
## Available Shared Interfaces
|
||||
|
||||
**REST:**
|
||||
|
||||
- Market data: `ISpotTickerRestClient`, `IBookTickerRestClient`, `ISpotSymbolRestClient`, `IFuturesSymbolRestClient`, `IOrderBookRestClient`, `IRecentTradeRestClient`, `IKlineRestClient`
|
||||
- Orders: `ISpotOrderRestClient`, `IFuturesOrderRestClient`, `ISpotOrderClientIdRestClient`, `IFuturesOrderClientIdRestClient`, `ISpotTriggerOrderRestClient`, `IFuturesTriggerOrderRestClient`, `IFuturesTpSlRestClient`
|
||||
- Account: `IBalanceRestClient`, `IPositionRestClient`, `IFeeRestClient`, `ITransferRestClient`, `IDepositRestClient`, `IWithdrawalRestClient`
|
||||
|
||||
**WebSocket:**
|
||||
|
||||
- `ITickerSocketClient`, `IBookTickerSocketClient`
|
||||
- `IOrderBookSocketClient`, `ITradeSocketClient`, `IKlineSocketClient`
|
||||
- `IUserTradeSocketClient`, `ISpotOrderSocketClient`, `IFuturesOrderSocketClient`, `IPositionSocketClient`, `IBalanceSocketClient`
|
||||
|
||||
Each exchange documents which interfaces it implements (some exchanges don't support every operation).
|
||||
|
||||
## Core Pattern: Result Handling
|
||||
|
||||
Same as exchange-specific libraries — `WebCallResult<T>` (REST) or `CallResult<T>` (WebSocket) with `.Success`, `.Data`, `.Error`. Always check `.Success` first.
|
||||
|
||||
```csharp
|
||||
var result = await sharedClient.GetSpotTickerAsync(new GetTickerRequest(symbol));
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.WriteLine($"[{sharedClient.Exchange}] Error: {result.Error}");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine($"[{sharedClient.Exchange}] {result.Data.LastPrice}");
|
||||
```
|
||||
|
||||
`.Exchange` property on every shared client tells you which exchange you're talking to — useful for logging.
|
||||
|
||||
## Core Pattern: Multi-Exchange Aggregation
|
||||
|
||||
```csharp
|
||||
var clients = new ISpotTickerRestClient[]
|
||||
{
|
||||
new BinanceRestClient().SpotApi.SharedClient,
|
||||
new OKXRestClient().UnifiedApi.SharedClient,
|
||||
new BybitRestClient().V5Api.SharedClient,
|
||||
};
|
||||
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// Fetch concurrently from all exchanges
|
||||
var tasks = clients.Select(c => c.GetSpotTickerAsync(new GetTickerRequest(symbol))).ToArray();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
if (results[i].Success)
|
||||
Console.WriteLine($"{clients[i].Exchange}: {results[i].Data!.LastPrice}");
|
||||
}
|
||||
```
|
||||
|
||||
## Per-Exchange Setup
|
||||
|
||||
Each exchange library has its own credentials class and options. See each library's CLAUDE.md for specifics. The pattern is consistent: `XxxRestClient(options => { options.ApiCredentials = new XxxCredentials(...); })`.
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Each exchange library has its own `services.AddXxx(...)` extension. They all share the same option-builder pattern. Register only the ones you use:
|
||||
|
||||
```csharp
|
||||
services.AddBinance(restOpts => { /*...*/ }, socketOpts => { /*...*/ });
|
||||
services.AddOKX(restOpts => { /*...*/ }, socketOpts => { /*...*/ });
|
||||
// Inject IBinanceRestClient, IOKXRestClient, etc.
|
||||
```
|
||||
|
||||
For one-package access: `services.AddCryptoClients(...)` from `CryptoClients.Net`.
|
||||
|
||||
## Common Pitfalls — AVOID
|
||||
|
||||
- **Do NOT install `CryptoExchange.Net` and try to call exchange APIs directly** — it's a base abstraction; you need an exchange library.
|
||||
- **Do NOT try to use one exchange's models with another's client** — use the SharedApis types (`SharedSymbol`, `SharedSpotTicker`, `SharedSpotOrder`, etc.) for cross-exchange code.
|
||||
- **Do NOT block on async operations** — use `await` throughout. `Task.WhenAll` for parallelism across exchanges.
|
||||
- **Do NOT assume every exchange supports every operation** — check exchange docs or the library's implementation. Operations may return errors like "not supported on this exchange".
|
||||
- **Do NOT instantiate clients per-request** — reuse via DI.
|
||||
- **Do NOT iterate exchanges sequentially when concurrency is fine** — use `Task.WhenAll` for ~Nx speedup.
|
||||
|
||||
## Implementing a New Exchange Library
|
||||
|
||||
If you're building a NEW exchange wrapper following the CryptoExchange.Net pattern (rare but valuable):
|
||||
|
||||
- Inherit from `RestApiClient` and `SocketApiClient` base classes
|
||||
- Define your own `XxxCredentials` extending `ApiCredentials` (or use `ApiCredentials` directly)
|
||||
- Implement `AuthenticationProvider` for the exchange's signing scheme
|
||||
- Implement the relevant `Shared*` interfaces on your API client classes for cross-exchange support
|
||||
- Follow the same `XxxRestOptions` / `XxxSocketOptions` pattern
|
||||
|
||||
See existing libraries (Binance.Net, Bybit.Net) as reference implementations.
|
||||
|
||||
## Reference
|
||||
|
||||
- Source: https://github.com/JKorf/CryptoExchange.Net
|
||||
- Documentation: https://cryptoexchange.jkorf.dev/
|
||||
- SharedApis docs: https://cryptoexchange.jkorf.dev/CryptoExchange.Net/idocs_shared.html
|
||||
- Bundle (all exchanges): https://github.com/JKorf/CryptoClients.Net
|
||||
- Demo app: https://github.com/JKorf/CryptoManager.Net
|
||||
- Discord: https://discord.gg/MSpeEtSY8t
|
||||
@@ -285,7 +285,7 @@ namespace CryptoExchange.Net.Converters.Protobuf
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CallResult<object>(new DeserializeError(ex.Message));
|
||||
return new CallResult<object>(new DeserializeError("Protobuf deserialization failed: " + ex.Message, ex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ namespace CryptoExchange.Net.Converters.Protobuf
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
return new CallResult<T>(new DeserializeError(ex.ToLogString()));
|
||||
return new CallResult<T>(new DeserializeError("Protobuf deserialization failed: " + ex.Message, ex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ namespace CryptoExchange.Net.Converters.Protobuf
|
||||
{
|
||||
// Not a json message
|
||||
IsValid = false;
|
||||
return Task.FromResult(new CallResult(new DeserializeError("ProtoBufError: " + ex.Message, ex)));
|
||||
return Task.FromResult(new CallResult(new DeserializeError("Protobuf deserialization failed: " + ex.Message, ex)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ namespace CryptoExchange.Net.Converters.Protobuf
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CallResult<object>(new DeserializeError(ex.ToLogString()));
|
||||
return new CallResult<object>(new DeserializeError("Protobuf deserialization failed: " + ex.Message, ex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +485,7 @@ namespace CryptoExchange.Net.Converters.Protobuf
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CallResult<T>(new DeserializeError(ex.Message));
|
||||
return new CallResult<T>(new DeserializeError("Protobuf deserialization failed: " + ex.Message, ex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,7 +504,7 @@ namespace CryptoExchange.Net.Converters.Protobuf
|
||||
{
|
||||
// Not a json message
|
||||
IsValid = false;
|
||||
return new CallResult(new DeserializeError("ProtobufError: " + ex.Message, ex));
|
||||
return new CallResult(new DeserializeError("Protobuf deserialization failed: " + ex.Message, ex));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0</TargetFrameworks>
|
||||
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<PackageId>CryptoExchange.Net.Protobuf</PackageId>
|
||||
<Authors>JKorf</Authors>
|
||||
<Description>Protobuf support for CryptoExchange.Net</Description>
|
||||
<PackageVersion>9.3.0</PackageVersion>
|
||||
<AssemblyVersion>9.3.0</AssemblyVersion>
|
||||
<FileVersion>9.3.0</FileVersion>
|
||||
<PackageVersion>10.0.1</PackageVersion>
|
||||
<AssemblyVersion>10.0.1</AssemblyVersion>
|
||||
<FileVersion>10.0.1</FileVersion>
|
||||
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||
<PackageTags>CryptoExchange;CryptoExchange.Net</PackageTags>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
@@ -41,7 +41,7 @@
|
||||
<DocumentationFile>CryptoExchange.Net.Protobuf.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CryptoExchange.Net" Version="9.3.0" />
|
||||
<PackageReference Include="protobuf-net" Version="3.2.52" />
|
||||
<PackageReference Include="CryptoExchange.Net" Version="10.0.2" />
|
||||
<PackageReference Include="protobuf-net" Version="3.2.56" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -5,6 +5,46 @@
|
||||
Protobuf support for CryptoExchange.Net.
|
||||
|
||||
## Release notes
|
||||
* Version 10.0.1 - 16 Dec 2025
|
||||
* Updated CryptoExchange.Net version to 10.0.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 10.0.0 - 16 Dec 2025
|
||||
* Updated CryptoExchange.Net version to 10.0.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.13.0 - 10 Nov 2025
|
||||
* Updated CryptoExchange.Net version to 9.13.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.12.0 - 03 Nov 2025
|
||||
* Updated CryptoExchange.Net version to 9.12.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.11.1 - 30 Oct 2025
|
||||
* Updated CryptoExchange.Net version to 9.11.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.11.0 - 30 Oct 2025
|
||||
* Updated CryptoExchange.Net version to 9.11.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.10.0 - 15 Oct 2025
|
||||
* Updated CryptoExchange.Net version to 9.10.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.9.0 - 06 Oct 2025
|
||||
* Updated CryptoExchange.Net version to 9.9.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.8.0 - 30 Sep 2025
|
||||
* Updated CryptoExchange.Net version to 9.8.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.7.0 - 01 Sep 2025
|
||||
* Updated CryptoExchange.Net version to 9.7.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
* Version 9.6.0 - 25 Aug 2025
|
||||
* Updated CryptoExchange.Net version to 9.6.0
|
||||
|
||||
* Version 9.5.0 - 19 Aug 2025
|
||||
* Updated CryptoExchange.Net version to 9.5.0
|
||||
|
||||
* Version 9.4.0 - 04 Aug 2025
|
||||
* Updated CryptoExchange.Net to version 9.4.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
* Updated protobuf-net package version to 3.2.56
|
||||
|
||||
* Version 9.3.0 - 23 Jul 2025
|
||||
* Updated CryptoExchange.Net to version 9.3.0, see https://github.com/JKorf/CryptoExchange.Net/releases/
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ using NUnit.Framework.Legacy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
@@ -96,7 +96,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
waiters.Add(evnt.WaitAsync());
|
||||
}
|
||||
|
||||
List<bool> results = null;
|
||||
List<bool>? results = null;
|
||||
var resultsWaiter = Task.Run(async () =>
|
||||
{
|
||||
await Task.WhenAll(waiters);
|
||||
@@ -112,7 +112,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
await resultsWaiter;
|
||||
|
||||
Assert.That(10 == results.Count(r => r));
|
||||
Assert.That(10 == results?.Count(r => r));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -140,5 +140,17 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
ClassicAssert.False(result1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CancellingWait_Should_ReturnFalse()
|
||||
{
|
||||
var evnt = new AsyncResetEvent(false, true);
|
||||
|
||||
var waiter1 = evnt.WaitAsync(ct: new CancellationTokenSource(50).Token);
|
||||
|
||||
var result1 = await waiter1;
|
||||
|
||||
ClassicAssert.False(result1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using CryptoExchange.Net;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
internal class BodySerializationTests
|
||||
{
|
||||
[Test]
|
||||
public void ToFormData_SerializesBasicValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", "1" },
|
||||
{ "b", 2 },
|
||||
{ "c", true }
|
||||
};
|
||||
|
||||
var parameterString = parameters.ToFormData();
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JsonSerializer_SerializesBasicValuesCorrectly()
|
||||
{
|
||||
var serializer = new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", "1" },
|
||||
{ "b", 2 },
|
||||
{ "c", true }
|
||||
};
|
||||
|
||||
var parameterString = serializer.Serialize(parameters);
|
||||
Assert.That(parameterString, Is.EqualTo("{\"a\":\"1\",\"b\":2,\"c\":true}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
@@ -16,9 +14,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[Test]
|
||||
public void TestBasicErrorCallResult()
|
||||
{
|
||||
var result = new CallResult(new ServerError("TestError"));
|
||||
var result = new CallResult(new ServerError("TestError", ErrorInfo.Unknown));
|
||||
|
||||
ClassicAssert.AreSame(result.Error.Message, "TestError");
|
||||
ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError");
|
||||
ClassicAssert.IsFalse(result);
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
}
|
||||
@@ -36,9 +34,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[Test]
|
||||
public void TestCallResultError()
|
||||
{
|
||||
var result = new CallResult<object>(new ServerError("TestError"));
|
||||
var result = new CallResult<object>(new ServerError("TestError", ErrorInfo.Unknown));
|
||||
|
||||
ClassicAssert.AreSame(result.Error.Message, "TestError");
|
||||
ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError");
|
||||
ClassicAssert.IsNull(result.Data);
|
||||
ClassicAssert.IsFalse(result);
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
@@ -71,11 +69,11 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[Test]
|
||||
public void TestCallResultErrorAs()
|
||||
{
|
||||
var result = new CallResult<TestObjectResult>(new ServerError("TestError"));
|
||||
var result = new CallResult<TestObjectResult>(new ServerError("TestError", ErrorInfo.Unknown));
|
||||
var asResult = result.As<TestObject2>(default);
|
||||
|
||||
ClassicAssert.IsNotNull(asResult.Error);
|
||||
ClassicAssert.AreSame(asResult.Error.Message, "TestError");
|
||||
ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError");
|
||||
ClassicAssert.IsNull(asResult.Data);
|
||||
ClassicAssert.IsFalse(asResult);
|
||||
ClassicAssert.IsFalse(asResult.Success);
|
||||
@@ -84,11 +82,11 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[Test]
|
||||
public void TestCallResultErrorAsError()
|
||||
{
|
||||
var result = new CallResult<TestObjectResult>(new ServerError("TestError"));
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2"));
|
||||
var result = new CallResult<TestObjectResult>(new ServerError("TestError", ErrorInfo.Unknown));
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2", ErrorInfo.Unknown));
|
||||
|
||||
ClassicAssert.IsNotNull(asResult.Error);
|
||||
ClassicAssert.AreSame(asResult.Error.Message, "TestError2");
|
||||
ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError2");
|
||||
ClassicAssert.IsNull(asResult.Data);
|
||||
ClassicAssert.IsFalse(asResult);
|
||||
ClassicAssert.IsFalse(asResult.Success);
|
||||
@@ -97,11 +95,11 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[Test]
|
||||
public void TestWebCallResultErrorAsError()
|
||||
{
|
||||
var result = new WebCallResult<TestObjectResult>(new ServerError("TestError"));
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2"));
|
||||
var result = new WebCallResult<TestObjectResult>(new ServerError("TestError", ErrorInfo.Unknown));
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2", ErrorInfo.Unknown));
|
||||
|
||||
ClassicAssert.IsNotNull(asResult.Error);
|
||||
ClassicAssert.AreSame(asResult.Error.Message, "TestError2");
|
||||
ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError2");
|
||||
ClassicAssert.IsNull(asResult.Data);
|
||||
ClassicAssert.IsFalse(asResult);
|
||||
ClassicAssert.IsFalse(asResult.Success);
|
||||
@@ -112,7 +110,8 @@ namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
var result = new WebCallResult<TestObjectResult>(
|
||||
System.Net.HttpStatusCode.OK,
|
||||
new KeyValuePair<string, string[]>[0],
|
||||
HttpVersion.Version11,
|
||||
new HttpResponseMessage().Headers,
|
||||
TimeSpan.FromSeconds(1),
|
||||
null,
|
||||
"{}",
|
||||
@@ -120,14 +119,14 @@ namespace CryptoExchange.Net.UnitTests
|
||||
"https://test.com/api",
|
||||
null,
|
||||
HttpMethod.Get,
|
||||
new KeyValuePair<string, string[]>[0],
|
||||
new HttpRequestMessage().Headers,
|
||||
ResultDataSource.Server,
|
||||
new TestObjectResult(),
|
||||
null);
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2"));
|
||||
var asResult = result.AsError<TestObject2>(new ServerError("TestError2", ErrorInfo.Unknown));
|
||||
|
||||
ClassicAssert.IsNotNull(asResult.Error);
|
||||
Assert.That(asResult.Error.Message == "TestError2");
|
||||
Assert.That(asResult.Error!.ErrorCode == "TestError2");
|
||||
Assert.That(asResult.ResponseStatusCode == System.Net.HttpStatusCode.OK);
|
||||
Assert.That(asResult.ResponseTime == TimeSpan.FromSeconds(1));
|
||||
Assert.That(asResult.RequestUrl == "https://test.com/api");
|
||||
@@ -142,7 +141,8 @@ namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
var result = new WebCallResult<TestObjectResult>(
|
||||
System.Net.HttpStatusCode.OK,
|
||||
new KeyValuePair<string, string[]>[0],
|
||||
HttpVersion.Version11,
|
||||
new HttpResponseMessage().Headers,
|
||||
TimeSpan.FromSeconds(1),
|
||||
null,
|
||||
"{}",
|
||||
@@ -150,7 +150,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
"https://test.com/api",
|
||||
null,
|
||||
HttpMethod.Get,
|
||||
new KeyValuePair<string, string[]>[0],
|
||||
new HttpRequestMessage().Headers,
|
||||
ResultDataSource.Server,
|
||||
new TestObjectResult(),
|
||||
null);
|
||||
|
||||
+12
-33
@@ -1,43 +1,22 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
namespace CryptoExchange.Net.UnitTests.ClientTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class BaseClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public void DeserializingValidJson_Should_GiveSuccessfulResult()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestBaseClient();
|
||||
//[TestCase]
|
||||
//public void DeserializingValidJson_Should_GiveSuccessfulResult()
|
||||
//{
|
||||
// // arrange
|
||||
// var client = new TestBaseClient();
|
||||
|
||||
// act
|
||||
var result = client.SubClient.Deserialize<object>("{\"testProperty\": 123}");
|
||||
// // act
|
||||
// var result = client.SubClient.Deserialize<object>("{\"testProperty\": 123}");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Success);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void DeserializingInvalidJson_Should_GiveErrorResult()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestBaseClient();
|
||||
|
||||
// act
|
||||
var result = client.SubClient.Deserialize<object>("{\"testProperty\": 123");
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
// // assert
|
||||
// Assert.That(result.Success);
|
||||
//}
|
||||
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")]
|
||||
[TestCase("https://api.test.com/api", new[] { "path1", "/path2" }, "https://api.test.com/api/path1/path2")]
|
||||
@@ -0,0 +1,151 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using NUnit.Framework.Legacy;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Guards;
|
||||
using CryptoExchange.Net.RateLimiting.Filters;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using System.Text.Json;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using CryptoExchange.Net.Testing;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ClientTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class RestClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public async Task RequestingData_Should_ResultInData()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() });
|
||||
client.ApiClient1.SetNextResponse(strData, System.Net.HttpStatusCode.OK);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
Assert.That(result.Success);
|
||||
Assert.That(TestHelpers.AreEqual(expected, result.Data));
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingInvalidData_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("{\"property\": 123", System.Net.HttpStatusCode.OK);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorCode_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("Invalid request", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndNotParsingError_Should_ResultInFlatError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is ServerError);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndNotParsingErrorAndInvalidJson_Should_ContainData()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
var response = "<html>...</html>";
|
||||
client.ApiClient1.SetNextResponse(response, System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is DeserializeError);
|
||||
Assert.That(result.Error!.Message!.Contains(response));
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndParsingError_Should_ResultInParsedError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is ServerError);
|
||||
Assert.That(result.Error!.ErrorCode == "123");
|
||||
Assert.That(result.Error.Message == "Invalid request");
|
||||
}
|
||||
|
||||
[TestCase("GET", HttpMethodParameterPosition.InUri)] // No need to test InBody for GET since thats not valid
|
||||
[TestCase("POST", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("POST", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InBody)]
|
||||
public async Task Setting_Should_ResultInOptionsSet(string method, HttpMethodParameterPosition pos)
|
||||
{
|
||||
// arrange
|
||||
// act
|
||||
var client = new TestRestClient();
|
||||
|
||||
var httpMethod = new HttpMethod(method);
|
||||
client.ApiClient1.SetParameterPosition(httpMethod, pos);
|
||||
client.ApiClient1.SetNextResponse("{}", System.Net.HttpStatusCode.OK);
|
||||
|
||||
var result = await client.ApiClient1.GetResponseAsync<TestObject>(httpMethod, new ParameterCollection
|
||||
{
|
||||
{ "TestParam1", "Value1" },
|
||||
{ "TestParam2", 2 },
|
||||
});
|
||||
|
||||
// assert
|
||||
Assert.That(result.RequestMethod == new HttpMethod(method));
|
||||
Assert.That(result.RequestBody?.Contains("TestParam1") == true == (pos == HttpMethodParameterPosition.InBody));
|
||||
Assert.That((result.RequestUrl?.ToString().Contains("TestParam1")) == (pos == HttpMethodParameterPosition.InUri));
|
||||
Assert.That(result.RequestBody?.Contains("TestParam2") == true == (pos == HttpMethodParameterPosition.InBody));
|
||||
Assert.That((result.RequestUrl?.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Testing;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ClientTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SocketClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public void SettingOptions_Should_ResultInOptionsSet()
|
||||
{
|
||||
//arrange
|
||||
//act
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ExchangeOptions.MaxSocketConnections = 1;
|
||||
});
|
||||
|
||||
//assert
|
||||
Assert.That(1 == client.ApiClient1.ApiOptions.MaxSocketConnections);
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public async Task ConnectSocket_Should_ReturnConnectionResult(bool canConnect)
|
||||
{
|
||||
//arrange
|
||||
var client = new TestSocketClient();
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
socket.CanConnect = canConnect;
|
||||
|
||||
//act
|
||||
var connectResult = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
//assert
|
||||
Assert.That(connectResult.Success == canConnect);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task SocketMessages_Should_BeProcessedInDataHandlers()
|
||||
{
|
||||
var client = new TestSocketClient();
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() });
|
||||
|
||||
TestObject? received = null;
|
||||
var resetEvent = new AsyncResetEvent(false);
|
||||
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x =>
|
||||
{
|
||||
received = x.Data;
|
||||
resetEvent.Set();
|
||||
}, false, default);
|
||||
|
||||
socket.InvokeMessage(strData);
|
||||
await resetEvent.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.That(received != null);
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled)
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
options.ExchangeOptions.OutputOriginalData = enabled;
|
||||
});
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() });
|
||||
|
||||
string? originalData = null;
|
||||
var resetEvent = new AsyncResetEvent(false);
|
||||
|
||||
await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x =>
|
||||
{
|
||||
originalData = x.OriginalData;
|
||||
resetEvent.Set();
|
||||
}, false, default);
|
||||
|
||||
socket.InvokeMessage(strData);
|
||||
await resetEvent.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
|
||||
// assert
|
||||
Assert.That(originalData == (enabled ? strData : null));
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task UnsubscribingStream_Should_CloseTheSocket()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
});
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
|
||||
var result = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => {}, false, default);
|
||||
|
||||
// act
|
||||
await client.UnsubscribeAsync(result.Data);
|
||||
|
||||
// assert
|
||||
Assert.That(socket.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task UnsubscribingAll_Should_CloseAllSockets()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
});
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var result = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
var socket2 = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var result2 = await client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, false, default);
|
||||
|
||||
// act
|
||||
await client.UnsubscribeAllAsync();
|
||||
|
||||
// assert
|
||||
Assert.That(socket.Connected == false);
|
||||
Assert.That(socket2.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task ErrorResponse_ShouldNot_ConfirmSubscription()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(opt =>
|
||||
{
|
||||
opt.OutputOriginalData = true;
|
||||
});
|
||||
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var subTask = client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, true, default);
|
||||
|
||||
socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "ErrorWithSub" }));
|
||||
|
||||
var result = await subTask;
|
||||
|
||||
// assert
|
||||
Assert.That(result.Success == false);
|
||||
Assert.That(result.Error!.Message!.Contains("ErrorWithSub"));
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task SuccessResponse_Should_ConfirmSubscription()
|
||||
{
|
||||
var client = new TestSocketClient();
|
||||
var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost");
|
||||
var subTask = client.ApiClient1.SubscribeToUpdatesAsync<TestObject>(x => { }, true, default);
|
||||
|
||||
socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "OK" }));
|
||||
|
||||
var result = await subTask;
|
||||
|
||||
var subscription = client.ApiClient1._socketConnections.Single().Value.Subscriptions.Single();
|
||||
Assert.That(subscription.Status == SubscriptionStatus.Subscribed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using CryptoExchange.Net.Converters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class ArrayConverterTests
|
||||
{
|
||||
[Test()]
|
||||
public void TestArrayConverter()
|
||||
{
|
||||
var data = new Test()
|
||||
{
|
||||
Prop1 = 2,
|
||||
Prop2 = null,
|
||||
Prop3 = "123",
|
||||
Prop3Again = "123",
|
||||
Prop4 = null,
|
||||
Prop5 = new Test2
|
||||
{
|
||||
Prop21 = 3,
|
||||
Prop22 = "456"
|
||||
},
|
||||
Prop6 = new Test3
|
||||
{
|
||||
Prop31 = 4,
|
||||
Prop32 = "789"
|
||||
},
|
||||
Prop7 = TestEnum.Two,
|
||||
TestInternal = new Test
|
||||
{
|
||||
Prop1 = 10
|
||||
},
|
||||
Prop8 = new Test3
|
||||
{
|
||||
Prop31 = 5,
|
||||
Prop32 = "101"
|
||||
},
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions()
|
||||
{
|
||||
TypeInfoResolver = new TestSerializerContext()
|
||||
};
|
||||
var serialized = JsonSerializer.Serialize(data);
|
||||
var deserialized = JsonSerializer.Deserialize<Test>(serialized);
|
||||
|
||||
Assert.That(deserialized!.Prop1, Is.EqualTo(2));
|
||||
Assert.That(deserialized.Prop2, Is.Null);
|
||||
Assert.That(deserialized.Prop3, Is.EqualTo("123"));
|
||||
Assert.That(deserialized.Prop3Again, Is.EqualTo("123"));
|
||||
Assert.That(deserialized.Prop4, Is.Null);
|
||||
Assert.That(deserialized.Prop5!.Prop21, Is.EqualTo(3));
|
||||
Assert.That(deserialized.Prop5!.Prop22, Is.EqualTo("456"));
|
||||
Assert.That(deserialized.Prop6!.Prop31, Is.EqualTo(4));
|
||||
Assert.That(deserialized.Prop6.Prop32, Is.EqualTo("789"));
|
||||
Assert.That(deserialized.Prop7, Is.EqualTo(TestEnum.Two));
|
||||
Assert.That(deserialized.TestInternal!.Prop1, Is.EqualTo(10));
|
||||
Assert.That(deserialized.Prop8!.Prop31, Is.EqualTo(5));
|
||||
Assert.That(deserialized.Prop8.Prop32, Is.EqualTo("101"));
|
||||
}
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ArrayConverter<Test>))]
|
||||
public record Test
|
||||
{
|
||||
[ArrayProperty(0)]
|
||||
public int Prop1 { get; set; }
|
||||
[ArrayProperty(1)]
|
||||
public int? Prop2 { get; set; }
|
||||
[ArrayProperty(2)]
|
||||
public string? Prop3 { get; set; }
|
||||
[ArrayProperty(2)]
|
||||
public string? Prop3Again { get; set; }
|
||||
[ArrayProperty(3)]
|
||||
public string? Prop4 { get; set; }
|
||||
[ArrayProperty(4)]
|
||||
public Test2? Prop5 { get; set; }
|
||||
[ArrayProperty(5)]
|
||||
public Test3? Prop6 { get; set; }
|
||||
[ArrayProperty(6), JsonConverter(typeof(EnumConverter<TestEnum>))]
|
||||
public TestEnum? Prop7 { get; set; }
|
||||
[ArrayProperty(7)]
|
||||
public Test? TestInternal { get; set; }
|
||||
[ArrayProperty(8), JsonConversion]
|
||||
public Test3? Prop8 { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ArrayConverter<Test2>))]
|
||||
public record Test2
|
||||
{
|
||||
[ArrayProperty(0)]
|
||||
public int Prop21 { get; set; }
|
||||
[ArrayProperty(1)]
|
||||
public string? Prop22 { get; set; }
|
||||
}
|
||||
|
||||
public record Test3
|
||||
{
|
||||
[JsonPropertyName("prop31")]
|
||||
public int Prop31 { get; set; }
|
||||
[JsonPropertyName("prop32")]
|
||||
public string? Prop32 { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class BoolConverterTests
|
||||
{
|
||||
[TestCase("1", true)]
|
||||
[TestCase("true", true)]
|
||||
[TestCase("yes", true)]
|
||||
[TestCase("y", true)]
|
||||
[TestCase("on", true)]
|
||||
[TestCase("-1", false)]
|
||||
[TestCase("0", false)]
|
||||
[TestCase("n", false)]
|
||||
[TestCase("no", false)]
|
||||
[TestCase("false", false)]
|
||||
[TestCase("off", false)]
|
||||
[TestCase("", null)]
|
||||
public void TestBoolConverter(string value, bool? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<STJBoolObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", true)]
|
||||
[TestCase("true", true)]
|
||||
[TestCase("yes", true)]
|
||||
[TestCase("y", true)]
|
||||
[TestCase("on", true)]
|
||||
[TestCase("-1", false)]
|
||||
[TestCase("0", false)]
|
||||
[TestCase("n", false)]
|
||||
[TestCase("no", false)]
|
||||
[TestCase("false", false)]
|
||||
[TestCase("off", false)]
|
||||
[TestCase("", false)]
|
||||
public void TestBoolConverterNotNullable(string value, bool expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<NotNullableSTJBoolObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
}
|
||||
|
||||
public class STJBoolObject
|
||||
{
|
||||
public bool? Value { get; set; }
|
||||
}
|
||||
|
||||
public class NotNullableSTJBoolObject
|
||||
{
|
||||
public bool Value { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class DateTimeConverterTests
|
||||
{
|
||||
[TestCase("2021-05-12")]
|
||||
[TestCase("20210512")]
|
||||
[TestCase("210512")]
|
||||
[TestCase("1620777600.000")]
|
||||
[TestCase("1620777600000")]
|
||||
[TestCase("2021-05-12T00:00:00.000Z")]
|
||||
[TestCase("2021-05-12T00:00:00.000000000Z")]
|
||||
[TestCase("0.000000", true)]
|
||||
[TestCase("0", true)]
|
||||
[TestCase("", true)]
|
||||
[TestCase(" ", true)]
|
||||
public void TestDateTimeConverterString(string input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": \"{input}\" }}");
|
||||
Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[TestCase(1620777600.000)]
|
||||
[TestCase(1620777600000d)]
|
||||
public void TestDateTimeConverterDouble(double input)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.That(output!.Time == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000000)]
|
||||
[TestCase(1620777600000000000)]
|
||||
[TestCase(0, true)]
|
||||
public void TestDateTimeConverterLong(long input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600.000)]
|
||||
public void TestDateTimeConverterFromSeconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromSeconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToSeconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000.000)]
|
||||
public void TestDateTimeConverterFromMilliseconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMilliseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMilliseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000)]
|
||||
public void TestDateTimeConverterFromMicroseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMicroseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMicroseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000000)]
|
||||
public void TestDateTimeConverterFromNanoseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromNanoseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToNanoseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000000000);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void TestDateTimeConverterNull()
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": null }}");
|
||||
Assert.That(output!.Time == null);
|
||||
}
|
||||
}
|
||||
|
||||
public class STJTimeObject
|
||||
{
|
||||
[JsonConverter(typeof(DateTimeConverter))]
|
||||
[JsonPropertyName("time")]
|
||||
public DateTime? Time { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using NUnit.Framework;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class DecimalConverterTests
|
||||
{
|
||||
[TestCase("1", 1)]
|
||||
[TestCase("1.1", 1.1)]
|
||||
[TestCase("-1.1", -1.1)]
|
||||
[TestCase(null, null)]
|
||||
[TestCase("", null)]
|
||||
[TestCase("null", null)]
|
||||
[TestCase("nan", null)]
|
||||
[TestCase("1E+2", 100)]
|
||||
[TestCase("1E-2", 0.01)]
|
||||
[TestCase("Infinity", 999)] // 999 is workaround for not being able to specify decimal.MinValue
|
||||
[TestCase("-Infinity", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
[TestCase("80228162514264337593543950335", 999)] // 999 is workaround for not being able to specify decimal.MaxValue
|
||||
[TestCase("-80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
public void TestDecimalConverterString(string value, decimal? expected)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<STJDecimalObject>("{ \"test\": \"" + value + "\"}");
|
||||
Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MinValue : expected == 999 ? decimal.MaxValue : expected));
|
||||
}
|
||||
|
||||
[TestCase("1", 1)]
|
||||
[TestCase("1.1", 1.1)]
|
||||
[TestCase("-1.1", -1.1)]
|
||||
[TestCase("null", null)]
|
||||
[TestCase("1E+2", 100)]
|
||||
[TestCase("1E-2", 0.01)]
|
||||
[TestCase("80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
public void TestDecimalConverterNumber(string value, decimal? expected)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<STJDecimalObject>("{ \"test\": " + value + "}");
|
||||
Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MaxValue : expected));
|
||||
}
|
||||
}
|
||||
|
||||
public class STJDecimalObject
|
||||
{
|
||||
[JsonConverter(typeof(DecimalConverter))]
|
||||
[JsonPropertyName("test")]
|
||||
public decimal? Test { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Testing;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
public class EnumConverterTests
|
||||
{
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.That(output == expected);
|
||||
}
|
||||
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
public void TestEnumConverterGetStringTests(TestEnum value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.That(output == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", null)]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<STJEnumObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", (TestEnum)(-9))]
|
||||
[TestCase(null, (TestEnum)(-9))]
|
||||
public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<NotNullableSTJEnumObject>($"{{ \"Value\": {val} }}");
|
||||
Assert.That(output!.Value == expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestEnumConverterMapsUndefinedValueCorrectlyIfDefaultIsDefined()
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<TestEnum2>($"\"TestUndefined\"");
|
||||
Assert.That((int)output == -99);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", null)]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterParseStringTests(string value, TestEnum? expected)
|
||||
{
|
||||
var result = EnumConverter.ParseString<TestEnum>(value);
|
||||
Assert.That(result == expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestEnumConverterParseNullOnNonNullableOnlyLogsOnce()
|
||||
{
|
||||
LibraryHelpers.StaticLogger = new TraceLogger();
|
||||
var listener = new EnumValueTraceListener();
|
||||
Trace.Listeners.Add(listener);
|
||||
EnumConverter<TestEnum>.Reset();
|
||||
try
|
||||
{
|
||||
Assert.Throws<Exception>(() =>
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<NotNullableSTJEnumObject>("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
});
|
||||
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
var result2 = JsonSerializer.Deserialize<NotNullableSTJEnumObject>("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Trace.Listeners.Remove(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
public class STJEnumObject
|
||||
{
|
||||
public TestEnum? Value { get; set; }
|
||||
}
|
||||
|
||||
public class NotNullableSTJEnumObject
|
||||
{
|
||||
public TestEnum Value { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(EnumConverter<TestEnum>))]
|
||||
public enum TestEnum
|
||||
{
|
||||
[Map("1")]
|
||||
One,
|
||||
[Map("2")]
|
||||
Two,
|
||||
[Map("three", "3")]
|
||||
Three,
|
||||
Four
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(EnumConverter<TestEnum2>))]
|
||||
public enum TestEnum2
|
||||
{
|
||||
[Map("-9")]
|
||||
Minus9 = -9,
|
||||
[Map("1")]
|
||||
One,
|
||||
[Map("2")]
|
||||
Two,
|
||||
[Map("three", "3")]
|
||||
Three,
|
||||
Four
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.ConverterTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class SharedModelConversionTests
|
||||
{
|
||||
[TestCase(TradingMode.Spot, "ETH", "USDT", null)]
|
||||
[TestCase(TradingMode.PerpetualLinear, "ETH", "USDT", null)]
|
||||
[TestCase(TradingMode.DeliveryLinear, "ETH", "USDT", 1748432430)]
|
||||
public void TestSharedSymbolConversion(TradingMode tradingMode, string baseAsset, string quoteAsset, int? deliverTime)
|
||||
{
|
||||
DateTime? time = deliverTime == null ? null : DateTimeConverter.ParseFromDouble(deliverTime.Value);
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, time);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(symbol);
|
||||
var restored = JsonSerializer.Deserialize<SharedSymbol>(serialized);
|
||||
|
||||
Assert.That(restored!.TradingMode, Is.EqualTo(symbol.TradingMode));
|
||||
Assert.That(restored.BaseAsset, Is.EqualTo(symbol.BaseAsset));
|
||||
Assert.That(restored.QuoteAsset, Is.EqualTo(symbol.QuoteAsset));
|
||||
Assert.That(restored.DeliverTime, Is.EqualTo(symbol.DeliverTime));
|
||||
}
|
||||
|
||||
[TestCase(0.1, null, null)]
|
||||
[TestCase(0.1, 0.1, null)]
|
||||
[TestCase(0.1, 0.1, 0.1)]
|
||||
[TestCase(null, 0.1, null)]
|
||||
[TestCase(null, 0.1, 0.1)]
|
||||
public void TestSharedQuantityConversion(double? baseQuantity, double? quoteQuantity, double? contractQuantity)
|
||||
{
|
||||
var symbol = new SharedOrderQuantity((decimal?)baseQuantity, (decimal?)quoteQuantity, (decimal?)contractQuantity);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(symbol);
|
||||
var restored = JsonSerializer.Deserialize<SharedOrderQuantity>(serialized);
|
||||
|
||||
Assert.That(restored!.QuantityInBaseAsset, Is.EqualTo(symbol.QuantityInBaseAsset));
|
||||
Assert.That(restored.QuantityInQuoteAsset, Is.EqualTo(symbol.QuantityInQuoteAsset));
|
||||
Assert.That(restored.QuantityInContracts, Is.EqualTo(symbol.QuantityInContracts));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"></PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"></PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NUnit" Version="4.3.2"></PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"></PackageReference>
|
||||
<PackageReference Include="NUnit" Version="4.4.0"></PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1"></PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
@@ -32,6 +30,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
[TestCase(0.1, 1, 0.0001, RoundingType.Closest, 0.532, 0.532)]
|
||||
[TestCase(0.1, 1, 0.0001, RoundingType.Down, 0.5516592, 0.5516)]
|
||||
[TestCase(0.1, 1, 0.0001, RoundingType.Closest, 0.5516592, 0.5517)]
|
||||
[TestCase(0, 1, 0.000000001, RoundingType.Closest, 0.0000097232, 0.000009723)]
|
||||
public void AdjustValueStepTests(decimal min, decimal max, decimal? step, RoundingType roundingType, decimal input, decimal expected)
|
||||
{
|
||||
var result = ExchangeHelpers.AdjustValueStep(min, max, step, roundingType, input);
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class ExchangeSymbolCacheTests
|
||||
{
|
||||
private SharedSpotSymbol[] CreateTestSymbols()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("BTC", "EUR", "BTCEUR", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "BTC", "ETHBTC", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("XRP", "USDT", "XRPUSDT", false, TradingMode.Spot)
|
||||
};
|
||||
}
|
||||
|
||||
private SharedSpotSymbol[] CreateFuturesSymbols()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT-PERP", true, TradingMode.PerpetualLinear),
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT-PERP", true, TradingMode.PerpetualLinear)
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_NewTopic_Should_AddToCache()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "NewExchange";
|
||||
var symbols = CreateTestSymbols();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
var hasCached = ExchangeSymbolCache.HasCached(topicId);
|
||||
|
||||
// assert
|
||||
Assert.That(hasCached, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_Should_StoreAllSymbols()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeWithSymbols";
|
||||
var symbols = CreateTestSymbols();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// assert
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "BTCUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "ETHUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "BTCEUR"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "ETHBTC"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "XRPUSDT"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_CalledTwiceWithinAnHour_Should_NotUpdate()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoUpdate";
|
||||
var initialSymbols = new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
var updatedSymbols = new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, initialSymbols);
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, updatedSymbols);
|
||||
|
||||
// assert - should still have only the initial symbol since less than 60 minutes passed
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "BTCUSDT"), Is.True);
|
||||
// The second update should not have been applied
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, "ETHUSDT"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_WithEmptyArray_Should_CreateEmptyCache()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "EmptyExchange";
|
||||
var symbols = Array.Empty<SharedSpotSymbol>();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
var hasCached = ExchangeSymbolCache.HasCached(topicId);
|
||||
|
||||
// assert
|
||||
Assert.That(hasCached, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasCached_NonExistentTopic_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.HasCached(nonExistentTopic);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasCached_ExistingTopicWithSymbols_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeWithData";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.HasCached(topicId);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasCached_ExistingTopicWithNoSymbols_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoData";
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, Array.Empty<SharedSpotSymbol>());
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.HasCached(topicId);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_ByName_ExistingSymbol_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeSupports";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_ByName_NonExistingSymbol_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoSupport";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, "LINKUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_ByName_NonExistentTopic_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(nonExistentTopic, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_ExistingSymbol_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeSharedSymbol";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_NonExistingSymbol_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoSharedSymbol";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "LINK", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_DifferentTradingMode_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeDifferentMode";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(topicId, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupportsSymbol_BySharedSymbol_NonExistentTopic_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
var sharedSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.SupportsSymbol(nonExistentTopic, sharedSymbol);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_ExistingBaseAsset_Should_ReturnMatchingSymbols()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeBaseAsset";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(2));
|
||||
Assert.That(result.Any(x => x.QuoteAsset == "USDT"), Is.True);
|
||||
Assert.That(result.Any(x => x.QuoteAsset == "EUR"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_CaseInsensitive_Should_ReturnMatchingSymbols()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeCaseInsensitive";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "btc");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_NonExistingBaseAsset_Should_ReturnEmptyArray()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoBaseAsset";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "LINK");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_NonExistentTopic_Should_ReturnEmptyArray()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(nonExistentTopic, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_ExistingSymbol_Should_ReturnSharedSymbol()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeParse";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result!.BaseAsset, Is.EqualTo("BTC"));
|
||||
Assert.That(result.QuoteAsset, Is.EqualTo("USDT"));
|
||||
Assert.That(result.TradingMode, Is.EqualTo(TradingMode.Spot));
|
||||
Assert.That(result.SymbolName, Is.EqualTo("BTCUSDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_NonExistingSymbol_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNoParse";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, "LINKUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_NullSymbolName_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeNullSymbol";
|
||||
var symbols = CreateTestSymbols();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(topicId, null);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseSymbol_NonExistentTopic_Should_ReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var nonExistentTopic = "NonExistent_" + Guid.NewGuid();
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.ParseSymbol(nonExistentTopic, "BTCUSDT");
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleTopics_Should_MaintainSeparateData()
|
||||
{
|
||||
// arrange
|
||||
var topic1 = "Exchange1";
|
||||
var topic2 = "Exchange2";
|
||||
var symbols1 = new[]
|
||||
{
|
||||
new SharedSpotSymbol("BTC", "USDT", "BTCUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
var symbols2 = new[]
|
||||
{
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot)
|
||||
};
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topic1, symbols1);
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topic2, symbols2);
|
||||
|
||||
// assert
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic1, "BTCUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic1, "ETHUSDT"), Is.False);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic2, "ETHUSDT"), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topic2, "BTCUSDT"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateSymbolInfo_WithDifferentTradingModes_Should_StoreCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeMixedModes";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
var futuresSymbols = CreateFuturesSymbols();
|
||||
var allSymbols = spotSymbols.Concat(futuresSymbols).ToArray();
|
||||
|
||||
// act
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, allSymbols);
|
||||
|
||||
// assert
|
||||
var spotSymbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var futuresSymbol = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, spotSymbol), Is.True);
|
||||
Assert.That(ExchangeSymbolCache.SupportsSymbol(topicId, futuresSymbol), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_Should_ReturnAllTradingModes()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeAllModes";
|
||||
var spotSymbols = CreateTestSymbols();
|
||||
var futuresSymbols = CreateFuturesSymbols();
|
||||
var allSymbols = spotSymbols.Concat(futuresSymbols).ToArray();
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, allSymbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "BTC");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(result.Any(x => x.TradingMode == TradingMode.Spot), Is.True);
|
||||
Assert.That(result.Any(x => x.TradingMode == TradingMode.PerpetualLinear), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbolsForBaseAsset_WithMultipleMatchingSymbols_Should_ReturnAll()
|
||||
{
|
||||
// arrange
|
||||
var topicId = "ExchangeMultiple";
|
||||
var symbols = new[]
|
||||
{
|
||||
new SharedSpotSymbol("ETH", "USDT", "ETHUSDT", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "BTC", "ETHBTC", true, TradingMode.Spot),
|
||||
new SharedSpotSymbol("ETH", "EUR", "ETHEUR", true, TradingMode.Spot)
|
||||
};
|
||||
ExchangeSymbolCache.UpdateSymbolInfo(topicId, symbols);
|
||||
|
||||
// act
|
||||
var result = ExchangeSymbolCache.GetSymbolsForBaseAsset(topicId, "ETH");
|
||||
|
||||
// assert
|
||||
Assert.That(result.Length, Is.EqualTo(3));
|
||||
Assert.That(result.All(x => x.BaseAsset == "ETH"), Is.True);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestAuthenticationProvider : AuthenticationProvider<TestCredentials, TestCredentials>
|
||||
{
|
||||
public TestAuthenticationProvider(TestCredentials credentials) : base(credentials, credentials)
|
||||
{
|
||||
}
|
||||
|
||||
public override void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig)
|
||||
{
|
||||
requestConfig.Headers ??= new Dictionary<string, string>();
|
||||
requestConfig.Headers["Authorization"] = Credential.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestCredentials : HMACCredential
|
||||
{
|
||||
public TestCredentials() { }
|
||||
|
||||
public TestCredentials(string key, string secret) : base(key, secret)
|
||||
{
|
||||
}
|
||||
|
||||
public TestCredentials(HMACCredential credential) : base(credential.Key, credential.Secret)
|
||||
{
|
||||
}
|
||||
|
||||
public TestCredentials WithHMAC(string key, string secret)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Key)) throw new InvalidOperationException("Credentials already set");
|
||||
|
||||
Key = key;
|
||||
Secret = secret;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new TestCredentials(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestEnvironment : TradeEnvironment
|
||||
{
|
||||
public string RestClientAddress { get; }
|
||||
public string SocketClientAddress { get; }
|
||||
|
||||
internal TestEnvironment(
|
||||
string name,
|
||||
string restAddress,
|
||||
string streamAddress) :
|
||||
base(name)
|
||||
{
|
||||
RestClientAddress = restAddress;
|
||||
SocketClientAddress = streamAddress;
|
||||
}
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
public TestEnvironment() : base(TradeEnvironmentNames.Live)
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Get the environment by name
|
||||
/// </summary>
|
||||
public static TestEnvironment? GetEnvironmentByName(string? name)
|
||||
=> name switch
|
||||
{
|
||||
TradeEnvironmentNames.Live => Live,
|
||||
"" => Live,
|
||||
null => Live,
|
||||
_ => default
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Available environment names
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static string[] All => [Live.Name];
|
||||
|
||||
/// <summary>
|
||||
/// Live environment
|
||||
/// </summary>
|
||||
public static TestEnvironment Live { get; }
|
||||
= new TestEnvironment(TradeEnvironmentNames.Live,
|
||||
"https://localhost",
|
||||
"wss://localhost");
|
||||
|
||||
/// <summary>
|
||||
/// Create a custom environment
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="spotRestAddress"></param>
|
||||
/// <param name="spotSocketStreamsAddress"></param>
|
||||
/// <returns></returns>
|
||||
public static TestEnvironment CreateCustom(
|
||||
string name,
|
||||
string spotRestAddress,
|
||||
string spotSocketStreamsAddress)
|
||||
=> new TestEnvironment(name, spotRestAddress, spotSocketStreamsAddress);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
public class TestObject
|
||||
{
|
||||
[JsonPropertyName("other")]
|
||||
public string StringData { get; set; }
|
||||
public string StringData { get; set; } = string.Empty;
|
||||
[JsonPropertyName("intData")]
|
||||
public int IntData { get; set; }
|
||||
[JsonPropertyName("decimalData")]
|
||||
@@ -0,0 +1,25 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestQuery : Query<TestSocketMessage>
|
||||
{
|
||||
public TestQuery(TestSocketMessage request, bool authenticated) : base(request, authenticated, 1)
|
||||
{
|
||||
MessageRouter = MessageRouter.CreateWithoutTopicFilter<TestSocketMessage>(request.Id.ToString(), HandleMessage);
|
||||
}
|
||||
|
||||
private CallResult? HandleMessage(SocketConnection connection, DateTime time, string? arg3, TestSocketMessage message)
|
||||
{
|
||||
if (message.Data != "OK")
|
||||
return new CallResult(new ServerError(ErrorInfo.Unknown with { Message = message.Data }));
|
||||
|
||||
return CallResult.SuccessResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using CryptoExchange.Net.Testing.Implementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestApiClient : RestApiClient<TestEnvironment, TestAuthenticationProvider, TestCredentials>
|
||||
{
|
||||
protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler();
|
||||
|
||||
public TestRestApiClient(ILogger logger, HttpClient? httpClient, TestRestOptions options)
|
||||
: base(logger, httpClient, options.Environment.RestClientAddress, options, options.ExchangeOptions)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) =>
|
||||
baseAsset + quoteAsset;
|
||||
|
||||
protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) =>
|
||||
new TestAuthenticationProvider(credentials);
|
||||
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
|
||||
internal void SetNextResponse(string data, HttpStatusCode code)
|
||||
{
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(data);
|
||||
var responseStream = new MemoryStream();
|
||||
responseStream.Write(expectedBytes, 0, expectedBytes.Length);
|
||||
responseStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var response = new TestResponse(code, responseStream);
|
||||
var request = new TestRequest(response);
|
||||
|
||||
var factory = new TestRequestFactory(request);
|
||||
RequestFactory = factory;
|
||||
}
|
||||
|
||||
internal async Task<WebCallResult<T>> GetResponseAsync<T>(HttpMethod? httpMethod = null, ParameterCollection? collection = null, RateLimitGate? rateLimitGate = null)
|
||||
{
|
||||
var definition = new RequestDefinition("/path", httpMethod ?? HttpMethod.Get)
|
||||
{
|
||||
Weight = rateLimitGate == null ? 0 : 1,
|
||||
RateLimitGate = rateLimitGate
|
||||
};
|
||||
return await SendAsync<T>(BaseAddress, definition, collection ?? new ParameterCollection(), default);
|
||||
}
|
||||
|
||||
internal void SetParameterPosition(HttpMethod httpMethod, HttpMethodParameterPosition pos)
|
||||
{
|
||||
ParameterPositions[httpMethod] = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestClient : BaseRestClient<TestEnvironment, TestCredentials>
|
||||
{
|
||||
public TestRestApiClient ApiClient1 { get; set; }
|
||||
public TestRestApiClient ApiClient2 { get; set; }
|
||||
|
||||
public TestRestClient(Action<TestRestOptions>? optionsDelegate = null)
|
||||
: this(null, null, Options.Create(ApplyOptionsDelegate(optionsDelegate)))
|
||||
{
|
||||
}
|
||||
|
||||
public TestRestClient(HttpClient? httpClient, ILoggerFactory? loggerFactory, IOptions<TestRestOptions> options) : base(loggerFactory, "Test")
|
||||
{
|
||||
Initialize(options.Value);
|
||||
|
||||
ApiClient1 = AddApiClient(new TestRestApiClient(_logger, httpClient, options.Value));
|
||||
ApiClient2 = AddApiClient(new TestRestApiClient(_logger, httpClient, options.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using System.IO;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestMessageHandler : JsonRestMessageHandler
|
||||
{
|
||||
public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext());
|
||||
|
||||
public override async ValueTask<Error> ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream)
|
||||
{
|
||||
var (jsonError, jsonDocument) = await GetJsonDocument(responseStream).ConfigureAwait(false);
|
||||
if (jsonError != null)
|
||||
return jsonError;
|
||||
|
||||
int? code = jsonDocument!.RootElement.TryGetProperty("errorCode", out var codeProp) ? codeProp.GetInt32() : null;
|
||||
var msg = jsonDocument.RootElement.TryGetProperty("errorMessage", out var msgProp) ? msgProp.GetString() : null;
|
||||
if (msg == null)
|
||||
return new ServerError(ErrorInfo.Unknown);
|
||||
|
||||
if (code == null)
|
||||
return new ServerError(ErrorInfo.Unknown with { Message = msg });
|
||||
|
||||
return new ServerError(code.Value, new ErrorInfo(ErrorType.Unknown, false, "Error") with { Message = msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestRestOptions : RestExchangeOptions<TestEnvironment, TestCredentials>
|
||||
{
|
||||
internal static TestRestOptions Default { get; set; } = new TestRestOptions()
|
||||
{
|
||||
Environment = TestEnvironment.Live,
|
||||
AutoTimestamp = true
|
||||
};
|
||||
|
||||
public TestRestOptions()
|
||||
{
|
||||
Default?.Set(this);
|
||||
}
|
||||
|
||||
public RestApiOptions ExchangeOptions { get; private set; } = new RestApiOptions();
|
||||
|
||||
internal TestRestOptions Set(TestRestOptions targetOptions)
|
||||
{
|
||||
targetOptions = base.Set<TestRestOptions>(targetOptions);
|
||||
targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions);
|
||||
return targetOptions;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using CryptoExchange.Net.UnitTests.ConverterTests;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object>))]
|
||||
[JsonSerializable(typeof(TestObject))]
|
||||
|
||||
[JsonSerializable(typeof(TestSocketMessage))]
|
||||
[JsonSerializable(typeof(Test))]
|
||||
[JsonSerializable(typeof(Test2))]
|
||||
[JsonSerializable(typeof(Test3))]
|
||||
[JsonSerializable(typeof(NotNullableSTJBoolObject))]
|
||||
[JsonSerializable(typeof(STJBoolObject))]
|
||||
[JsonSerializable(typeof(NotNullableSTJEnumObject))]
|
||||
[JsonSerializable(typeof(STJEnumObject))]
|
||||
[JsonSerializable(typeof(STJDecimalObject))]
|
||||
[JsonSerializable(typeof(STJTimeObject))]
|
||||
internal partial class TestSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketApiClient : SocketApiClient<TestEnvironment, TestAuthenticationProvider, TestCredentials>
|
||||
{
|
||||
public TestSocketApiClient(ILogger logger, TestSocketOptions options)
|
||||
: base(logger, options.Environment.SocketClientAddress, options, options.ExchangeOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public TestSocketApiClient(ILogger logger, HttpClient httpClient, string baseAddress, TestSocketOptions options, SocketApiOptions apiOptions)
|
||||
: base(logger, baseAddress, options, apiOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public override ISocketMessageHandler CreateMessageConverter(WebSocketMessageType messageType) => new TestSocketMessageHandler();
|
||||
protected internal override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext()));
|
||||
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) =>
|
||||
baseAsset + quoteAsset;
|
||||
|
||||
protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) =>
|
||||
new TestAuthenticationProvider(credentials);
|
||||
|
||||
public async Task<CallResult<UpdateSubscription>> SubscribeToUpdatesAsync<T>(Action<DataEvent<T>> handler, bool subQuery, CancellationToken ct)
|
||||
{
|
||||
return await base.SubscribeAsync(new TestSubscription<T>(_logger, handler, subQuery, false), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using CryptoExchange.Net.Clients;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketClient : BaseSocketClient<TestEnvironment, TestCredentials>
|
||||
{
|
||||
public TestSocketApiClient ApiClient1 { get; set; }
|
||||
public TestSocketApiClient ApiClient2 { get; set; }
|
||||
|
||||
public TestSocketClient(Action<TestSocketOptions>? optionsDelegate = null)
|
||||
: this(null, Options.Create(ApplyOptionsDelegate(optionsDelegate)))
|
||||
{
|
||||
}
|
||||
|
||||
public TestSocketClient(ILoggerFactory? loggerFactory, IOptions<TestSocketOptions> options) : base(loggerFactory, "Test")
|
||||
{
|
||||
Initialize(options.Value);
|
||||
|
||||
ApiClient1 = AddApiClient(new TestSocketApiClient(_logger, options.Value));
|
||||
ApiClient2 = AddApiClient(new TestSocketApiClient(_logger, options.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal record TestSocketMessage
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
[JsonPropertyName("data")]
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketMessageHandler : JsonSocketMessageHandler
|
||||
{
|
||||
public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext());
|
||||
|
||||
public TestSocketMessageHandler()
|
||||
{
|
||||
}
|
||||
|
||||
protected override MessageTypeDefinition[] TypeEvaluators { get; } = [
|
||||
|
||||
new MessageTypeDefinition {
|
||||
ForceIfFound = true,
|
||||
Fields = [
|
||||
new PropertyFieldReference("id")
|
||||
],
|
||||
TypeIdentifierCallback = (doc) => doc.FieldValue("id")!
|
||||
},
|
||||
|
||||
new MessageTypeDefinition {
|
||||
Fields = [
|
||||
],
|
||||
StaticIdentifier = "test"
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSocketOptions : SocketExchangeOptions<TestEnvironment, TestCredentials>
|
||||
{
|
||||
internal static TestSocketOptions Default { get; set; } = new TestSocketOptions()
|
||||
{
|
||||
Environment = TestEnvironment.Live,
|
||||
AutoTimestamp = true
|
||||
};
|
||||
|
||||
public TestSocketOptions()
|
||||
{
|
||||
Default?.Set(this);
|
||||
}
|
||||
|
||||
public SocketApiOptions ExchangeOptions { get; private set; } = new SocketApiOptions();
|
||||
|
||||
internal TestSocketOptions Set(TestSocketOptions targetOptions)
|
||||
{
|
||||
targetOptions = base.Set<TestSocketOptions>(targetOptions);
|
||||
targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions);
|
||||
return targetOptions;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.Implementations
|
||||
{
|
||||
internal class TestSubscription<T> : Subscription
|
||||
{
|
||||
private readonly Action<DataEvent<T>> _handler;
|
||||
private bool _subQuery;
|
||||
|
||||
public TestSubscription(ILogger logger, Action<DataEvent<T>> handler, bool subQuery, bool authenticated) : base(logger, authenticated, true)
|
||||
{
|
||||
_handler = handler;
|
||||
_subQuery = subQuery;
|
||||
|
||||
MessageRouter = MessageRouter.CreateWithoutTopicFilter<T>("test", HandleUpdate);
|
||||
}
|
||||
|
||||
protected override Query? GetSubQuery(SocketConnection connection)
|
||||
{
|
||||
if (!_subQuery)
|
||||
return null;
|
||||
|
||||
return new TestQuery(new TestSocketMessage { Id = 1, Data = "Sub" }, false);
|
||||
}
|
||||
|
||||
protected override Query? GetUnsubQuery(SocketConnection connection)
|
||||
{
|
||||
if (!_subQuery)
|
||||
return null;
|
||||
|
||||
return new TestQuery(new TestSocketMessage { Id = 2, Data = "Unsub" }, false);
|
||||
}
|
||||
|
||||
|
||||
private CallResult? HandleUpdate(SocketConnection connection, DateTime time, string? originalData, T data)
|
||||
{
|
||||
_handler(new DataEvent<T>("Test", data, time, originalData));
|
||||
return CallResult.SuccessResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
@@ -17,9 +11,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
public class OptionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void Init()
|
||||
public void TearDown()
|
||||
{
|
||||
TestClientOptions.Default = new TestClientOptions
|
||||
TestRestOptions.Default = new TestRestOptions
|
||||
{
|
||||
};
|
||||
}
|
||||
@@ -36,135 +30,123 @@ namespace CryptoExchange.Net.UnitTests
|
||||
// act
|
||||
// assert
|
||||
Assert.Throws(typeof(ArgumentException),
|
||||
() => new RestExchangeOptions<TestEnvironment, ApiCredentials>() { ApiCredentials = new ApiCredentials(key, secret) });
|
||||
() => {
|
||||
var opts = new TestRestOptions()
|
||||
{
|
||||
ApiCredentials = new TestCredentials(key, secret)
|
||||
};
|
||||
opts.ApiCredentials.Validate();
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestBasicOptionsAreSet()
|
||||
{
|
||||
// arrange, act
|
||||
var options = new TestClientOptions
|
||||
var options = new TestRestOptions
|
||||
{
|
||||
ApiCredentials = new ApiCredentials("123", "456"),
|
||||
ReceiveWindow = TimeSpan.FromSeconds(10)
|
||||
ApiCredentials = new TestCredentials("123", "456"),
|
||||
RequestTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
// assert
|
||||
Assert.That(options.ReceiveWindow == TimeSpan.FromSeconds(10));
|
||||
Assert.That(options.RequestTimeout == TimeSpan.FromSeconds(10));
|
||||
Assert.That(options.ApiCredentials.Key == "123");
|
||||
Assert.That(options.ApiCredentials.Secret == "456");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestApiOptionsAreSet()
|
||||
public void TestSetOptionsRest()
|
||||
{
|
||||
// arrange, act
|
||||
var options = new TestClientOptions();
|
||||
options.Api1Options.ApiCredentials = new ApiCredentials("123", "456");
|
||||
options.Api2Options.ApiCredentials = new ApiCredentials("789", "101");
|
||||
|
||||
// assert
|
||||
Assert.That(options.Api1Options.ApiCredentials.Key == "123");
|
||||
Assert.That(options.Api1Options.ApiCredentials.Secret == "456");
|
||||
Assert.That(options.Api2Options.ApiCredentials.Key == "789");
|
||||
Assert.That(options.Api2Options.ApiCredentials.Secret == "101");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestClientUsesCorrectOptions()
|
||||
{
|
||||
var client = new TestRestClient(options => {
|
||||
options.Api1Options.ApiCredentials = new ApiCredentials("111", "222");
|
||||
options.ApiCredentials = new ApiCredentials("333", "444");
|
||||
});
|
||||
|
||||
var authProvider1 = (TestAuthProvider)client.Api1.AuthenticationProvider;
|
||||
var authProvider2 = (TestAuthProvider)client.Api2.AuthenticationProvider;
|
||||
Assert.That(authProvider1.GetKey() == "111");
|
||||
Assert.That(authProvider1.GetSecret() == "222");
|
||||
Assert.That(authProvider2.GetKey() == "333");
|
||||
Assert.That(authProvider2.GetSecret() == "444");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestClientUsesCorrectOptionsWithDefault()
|
||||
{
|
||||
TestClientOptions.Default.ApiCredentials = new ApiCredentials("123", "456");
|
||||
TestClientOptions.Default.Api1Options.ApiCredentials = new ApiCredentials("111", "222");
|
||||
|
||||
var client = new TestRestClient();
|
||||
client.SetOptions(new UpdateOptions
|
||||
{
|
||||
RequestTimeout = TimeSpan.FromSeconds(2),
|
||||
Proxy = new ApiProxy("http://testproxy", 1234)
|
||||
});
|
||||
|
||||
var authProvider1 = (TestAuthProvider)client.Api1.AuthenticationProvider;
|
||||
var authProvider2 = (TestAuthProvider)client.Api2.AuthenticationProvider;
|
||||
Assert.That(authProvider1.GetKey() == "111");
|
||||
Assert.That(authProvider1.GetSecret() == "222");
|
||||
Assert.That(authProvider2.GetKey() == "123");
|
||||
Assert.That(authProvider2.GetSecret() == "456");
|
||||
|
||||
// Cleanup static values
|
||||
TestClientOptions.Default.ApiCredentials = null;
|
||||
TestClientOptions.Default.Api1Options.ApiCredentials = null;
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null);
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy"));
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234));
|
||||
Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestClientUsesCorrectOptionsWithOverridingDefault()
|
||||
public void TestSetOptionsRestWithCredentials()
|
||||
{
|
||||
TestClientOptions.Default.ApiCredentials = new ApiCredentials("123", "456");
|
||||
TestClientOptions.Default.Api1Options.ApiCredentials = new ApiCredentials("111", "222");
|
||||
|
||||
var client = new TestRestClient(options =>
|
||||
var client = new TestRestClient();
|
||||
client.SetOptions(new UpdateOptions<TestCredentials>
|
||||
{
|
||||
options.Api1Options.ApiCredentials = new ApiCredentials("333", "444");
|
||||
options.Environment = new TestEnvironment("Test", "https://test.test");
|
||||
ApiCredentials = new TestCredentials("123", "456"),
|
||||
RequestTimeout = TimeSpan.FromSeconds(2),
|
||||
Proxy = new ApiProxy("http://testproxy", 1234)
|
||||
});
|
||||
|
||||
var authProvider1 = (TestAuthProvider)client.Api1.AuthenticationProvider;
|
||||
var authProvider2 = (TestAuthProvider)client.Api2.AuthenticationProvider;
|
||||
Assert.That(authProvider1.GetKey() == "333");
|
||||
Assert.That(authProvider1.GetSecret() == "444");
|
||||
Assert.That(authProvider2.GetKey() == "123");
|
||||
Assert.That(authProvider2.GetSecret() == "456");
|
||||
Assert.That(client.Api2.BaseAddress == "https://localhost:123");
|
||||
Assert.That(client.ApiClient1.ApiCredentials, Is.Not.Null);
|
||||
Assert.That(client.ApiClient1.ApiCredentials!.Key, Is.EqualTo("123"));
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null);
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy"));
|
||||
Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234));
|
||||
Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
|
||||
// Cleanup static values
|
||||
TestClientOptions.Default.ApiCredentials = null;
|
||||
TestClientOptions.Default.Api1Options.ApiCredentials = null;
|
||||
[Test]
|
||||
public void TestWhenUpdatingSettingsExistingClientsAreNotAffected()
|
||||
{
|
||||
TestRestOptions.Default = new TestRestOptions
|
||||
{
|
||||
ApiCredentials = new TestCredentials("111", "222"),
|
||||
RequestTimeout = TimeSpan.FromSeconds(1),
|
||||
};
|
||||
|
||||
var client1 = new TestRestClient();
|
||||
|
||||
Assert.That(client1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(1)));
|
||||
Assert.That(client1.ClientOptions.ApiCredentials!.Key, Is.EqualTo("111"));
|
||||
|
||||
TestRestOptions.Default.ApiCredentials = new TestCredentials("333", "444");
|
||||
TestRestOptions.Default.RequestTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
var client2 = new TestRestClient();
|
||||
|
||||
Assert.That(client2.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2)));
|
||||
Assert.That(client2.ClientOptions.ApiCredentials!.Key, Is.EqualTo("333"));
|
||||
}
|
||||
}
|
||||
|
||||
public class TestClientOptions: RestExchangeOptions<TestEnvironment, ApiCredentials>
|
||||
{
|
||||
/// <summary>
|
||||
/// Default options for the futures client
|
||||
/// </summary>
|
||||
public static TestClientOptions Default { get; set; } = new TestClientOptions()
|
||||
{
|
||||
Environment = new TestEnvironment("test", "https://test.com")
|
||||
};
|
||||
//public class TestClientOptions: RestExchangeOptions<TestEnvironment, HMACCredential>
|
||||
//{
|
||||
// /// <summary>
|
||||
// /// Default options for the futures client
|
||||
// /// </summary>
|
||||
// public static TestClientOptions Default { get; set; } = new TestClientOptions()
|
||||
// {
|
||||
// Environment = new TestEnvironment("test", "https://test.com")
|
||||
// };
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public TestClientOptions()
|
||||
{
|
||||
Default?.Set(this);
|
||||
}
|
||||
// /// <summary>
|
||||
// /// ctor
|
||||
// /// </summary>
|
||||
// public TestClientOptions()
|
||||
// {
|
||||
// Default?.Set(this);
|
||||
// }
|
||||
|
||||
/// <summary>
|
||||
/// The default receive window for requests
|
||||
/// </summary>
|
||||
public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5);
|
||||
// /// <summary>
|
||||
// /// The default receive window for requests
|
||||
// /// </summary>
|
||||
// public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public RestApiOptions Api1Options { get; private set; } = new RestApiOptions();
|
||||
// public RestApiOptions Api1Options { get; private set; } = new RestApiOptions();
|
||||
|
||||
public RestApiOptions Api2Options { get; set; } = new RestApiOptions();
|
||||
// public RestApiOptions Api2Options { get; set; } = new RestApiOptions();
|
||||
|
||||
internal TestClientOptions Set(TestClientOptions targetOptions)
|
||||
{
|
||||
targetOptions = base.Set<TestClientOptions>(targetOptions);
|
||||
targetOptions.Api1Options = Api1Options.Set(targetOptions.Api1Options);
|
||||
targetOptions.Api2Options = Api2Options.Set(targetOptions.Api2Options);
|
||||
return targetOptions;
|
||||
}
|
||||
}
|
||||
// internal TestClientOptions Set(TestClientOptions targetOptions)
|
||||
// {
|
||||
// targetOptions = base.Set<TestClientOptions>(targetOptions);
|
||||
// targetOptions.Api1Options = Api1Options.Set(targetOptions.Api1Options);
|
||||
// targetOptions.Api2Options = Api2Options.Set(targetOptions.Api2Options);
|
||||
// return targetOptions;
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.ConverterTests;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
internal class ParameterCollectionTests
|
||||
{
|
||||
[Test]
|
||||
public void AddingBasicValue_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.Add("test", "value");
|
||||
Assert.That(parameters["test"], Is.EqualTo("value"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingBasicNullValue_ThrowsException()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
Assert.Throws<ArgumentNullException>(() => parameters.Add("test", null!));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalBasicValue_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptional("test", "value");
|
||||
Assert.That(parameters["test"], Is.EqualTo("value"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalBasicNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptional("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingDecimalValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddString("test", 0.1m);
|
||||
Assert.That(parameters["test"], Is.EqualTo("0.1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalDecimalValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalString("test", 0.1m);
|
||||
Assert.That(parameters["test"], Is.EqualTo("0.1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalDecimalNullValueAsString_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalString("test", (decimal?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingIntValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddString("test", 1);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalIntValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalString("test", 1);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalIntNullValueAsString_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalString("test", (int?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingLongValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddString("test", 1L);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalLongValueAsString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalString("test", 1L);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalLongNullValueAsString_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalString("test", (long?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingMillisecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddMilliseconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600000));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalMillisecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalMilliseconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600000));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalMillisecondNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalMilliseconds("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingMillisecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddMillisecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600000"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalMillisecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalMillisecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600000"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalMillisecondStringNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalMillisecondsString("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingSecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddSeconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalSecondTimestamp_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalSeconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo(1735689600));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingSecondNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalSeconds("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingSecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddSecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalSecondTimestampString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalSecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(parameters["test"], Is.EqualTo("1735689600"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingSecondStringNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalSecondsString("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingEnum_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddEnum("test", TestEnum.Two);
|
||||
Assert.That(parameters["test"], Is.EqualTo("2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalEnum_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalEnum("test", (TestEnum?)TestEnum.Two);
|
||||
Assert.That(parameters["test"], Is.EqualTo("2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalEnumNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalEnum("test", (TestEnum?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingEnumAsInt_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddEnumAsInt("test", TestEnum.Two);
|
||||
Assert.That(parameters["test"], Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalEnumAsInt_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalEnumAsInt("test", (TestEnum?)TestEnum.Two);
|
||||
Assert.That(parameters["test"], Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalEnumAsIntNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalEnumAsInt("test", (TestEnum?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingCommaSeparated_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddCommaSeparated("test", ["1", "2"]);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1,2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalCommaSeparated_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalCommaSeparated("test", ["1", "2"]);
|
||||
Assert.That(parameters["test"], Is.EqualTo("1,2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalCommaSeparatedNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalCommaSeparated("test", (string[]?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingCommaSeparatedEnum_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddCommaSeparated("test", [TestEnum.Two, TestEnum.One]);
|
||||
Assert.That(parameters["test"], Is.EqualTo("2,1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalCommaSeparatedEnum_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalCommaSeparated("test", [TestEnum.Two, TestEnum.One]);
|
||||
Assert.That(parameters["test"], Is.EqualTo("2,1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalCommaSeparatedEnumNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalCommaSeparated("test", (TestEnum[]?)null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingBoolString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddBoolString("test", true);
|
||||
Assert.That(parameters["test"], Is.EqualTo("true"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalBoolString_SetValueCorrectly()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalBoolString("test", true);
|
||||
Assert.That(parameters["test"], Is.EqualTo("true"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddingOptionalBoolStringNullValue_DoesntSetValue()
|
||||
{
|
||||
var parameters = new ParameterCollection();
|
||||
parameters.AddOptionalBoolString("test", null);
|
||||
Assert.That(parameters.ContainsKey("test"), Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
-163
@@ -1,160 +1,23 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Filters;
|
||||
using CryptoExchange.Net.RateLimiting.Guards;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using CryptoExchange.Net.UnitTests.Implementations;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using NUnit.Framework.Legacy;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using System.Net;
|
||||
using CryptoExchange.Net.RateLimiting.Guards;
|
||||
using CryptoExchange.Net.RateLimiting.Filters;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class RestClientTests
|
||||
public class RateLimitTests
|
||||
{
|
||||
[TestCase]
|
||||
public void RequestingData_Should_ResultInData()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" };
|
||||
client.SetResponse(JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }), out _);
|
||||
|
||||
// act
|
||||
var result = client.Api1.Request<TestObject>().Result;
|
||||
|
||||
// assert
|
||||
Assert.That(result.Success);
|
||||
Assert.That(TestHelpers.AreEqual(expected, result.Data));
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void ReceivingInvalidData_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.SetResponse("{\"property\": 123", out _);
|
||||
|
||||
// act
|
||||
var result = client.Api1.Request<TestObject>().Result;
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorCode_Should_ResultInError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.SetErrorWithoutResponse(System.Net.HttpStatusCode.BadRequest, "Invalid request");
|
||||
|
||||
// act
|
||||
var result = await client.Api1.Request<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndNotParsingError_Should_ResultInFlatError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestRestClient();
|
||||
client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.Api1.Request<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is ServerError);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public async Task ReceivingErrorAndParsingError_Should_ResultInParsedError()
|
||||
{
|
||||
// arrange
|
||||
var client = new ParseErrorTestRestClient();
|
||||
client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest);
|
||||
|
||||
// act
|
||||
var result = await client.Api2.Request<TestObject>();
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(result.Success);
|
||||
Assert.That(result.Error != null);
|
||||
Assert.That(result.Error is ServerError);
|
||||
Assert.That(result.Error.Code == 123);
|
||||
Assert.That(result.Error.Message == "Invalid request");
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void SettingOptions_Should_ResultInOptionsSet()
|
||||
{
|
||||
// arrange
|
||||
// act
|
||||
var options = new TestClientOptions();
|
||||
options.Api1Options.TimestampRecalculationInterval = TimeSpan.FromMinutes(10);
|
||||
options.Api1Options.OutputOriginalData = true;
|
||||
options.RequestTimeout = TimeSpan.FromMinutes(1);
|
||||
var client = new TestBaseClient(options);
|
||||
|
||||
// assert
|
||||
Assert.That(((TestClientOptions)client.ClientOptions).Api1Options.TimestampRecalculationInterval == TimeSpan.FromMinutes(10));
|
||||
Assert.That(((TestClientOptions)client.ClientOptions).Api1Options.OutputOriginalData == true);
|
||||
Assert.That(((TestClientOptions)client.ClientOptions).RequestTimeout == TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[TestCase("GET", HttpMethodParameterPosition.InUri)] // No need to test InBody for GET since thats not valid
|
||||
[TestCase("POST", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("POST", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InBody)]
|
||||
[TestCase("DELETE", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InUri)]
|
||||
[TestCase("PUT", HttpMethodParameterPosition.InBody)]
|
||||
public async Task Setting_Should_ResultInOptionsSet(string method, HttpMethodParameterPosition pos)
|
||||
{
|
||||
// arrange
|
||||
// act
|
||||
var client = new TestRestClient();
|
||||
|
||||
client.Api1.SetParameterPosition(new HttpMethod(method), pos);
|
||||
|
||||
client.SetResponse("{}", out var request);
|
||||
|
||||
await client.Api1.RequestWithParams<TestObject>(new HttpMethod(method), new ParameterCollection
|
||||
{
|
||||
{ "TestParam1", "Value1" },
|
||||
{ "TestParam2", 2 },
|
||||
},
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "TestHeader", "123" }
|
||||
});
|
||||
|
||||
// assert
|
||||
Assert.That(request.Method == new HttpMethod(method));
|
||||
Assert.That((request.Content?.Contains("TestParam1") == true) == (pos == HttpMethodParameterPosition.InBody));
|
||||
Assert.That((request.Uri.ToString().Contains("TestParam1")) == (pos == HttpMethodParameterPosition.InUri));
|
||||
Assert.That((request.Content?.Contains("TestParam2") == true) == (pos == HttpMethodParameterPosition.InBody));
|
||||
Assert.That((request.Uri.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri));
|
||||
Assert.That(request.GetHeaders().First().Key == "TestHeader");
|
||||
Assert.That(request.GetHeaders().First().Value.Contains("123"));
|
||||
}
|
||||
|
||||
|
||||
[TestCase(1, 0.1)]
|
||||
[TestCase(2, 0.1)]
|
||||
[TestCase(5, 1)]
|
||||
@@ -170,19 +33,19 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(i == requests? triggered : !triggered);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, 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, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
Assert.That(!triggered);
|
||||
}
|
||||
|
||||
[TestCase("/sapi/test1", true)]
|
||||
[TestCase("/sapi/test2", true)]
|
||||
[TestCase("/api/test1", false)]
|
||||
[TestCase("sapi/test1", false)]
|
||||
[TestCase("sapi/test1", true)]
|
||||
[TestCase("/sapi/", true)]
|
||||
public async Task PartialEndpointRateLimiterEndpoints(string endpoint, bool expectLimiting)
|
||||
{
|
||||
@@ -191,12 +54,12 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
bool expected = i == 1 ? (expectLimiting ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null;
|
||||
bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +76,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
|
||||
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
@@ -253,15 +116,15 @@ namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathFilter("/sapi/test"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
|
||||
|
||||
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
bool expected = i == 1 ? (expectLimited ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null;
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
}
|
||||
@@ -276,12 +139,12 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathsFilter(new[] { "/sapi/test", "/sapi/test2" }), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get);
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
bool expected = i == 1 ? (expectLimited ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null;
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
}
|
||||
@@ -300,7 +163,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get) { Authenticated = key1 != null };
|
||||
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = key2 != null };
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", key1, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
@@ -319,7 +182,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
|
||||
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true };
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
@@ -339,7 +202,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get);
|
||||
var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true };
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, host1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
@@ -356,7 +219,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed));
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), host1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
@@ -371,7 +234,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
|
||||
|
||||
RateLimitEvent evnt = null;
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
@@ -379,5 +242,84 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
|
||||
Assert.That(result2.Error, Is.TypeOf<CancellationRequestedError>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RateLimiterReset_Should_AllowNextRequestForSameDefinition()
|
||||
{
|
||||
// arrange
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerConnection, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
|
||||
|
||||
var definition = new RequestDefinition("1", HttpMethod.Get) { ConnectionId = 1 };
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition, "https://test.com", null, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RateLimiterReset_Should_NotAllowNextRequestForDifferentDefinition()
|
||||
{
|
||||
// arrange
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerConnection, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed));
|
||||
|
||||
var definition1 = new RequestDefinition("1", HttpMethod.Get) { ConnectionId = 1 };
|
||||
var definition2 = new RequestDefinition("2", HttpMethod.Get) { ConnectionId = 2 };
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition1, "https://test.com", null, null, default);
|
||||
var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, "https://test.com", null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Not.Null);
|
||||
}
|
||||
|
||||
[TestCase(null, null, true)]
|
||||
[TestCase("Group1", null, false)]
|
||||
[TestCase(null, "Group2", false)]
|
||||
[TestCase("Group1", "Group2", false)]
|
||||
[TestCase("Group3", "Group3", true)]
|
||||
public async Task RateLimiterWithDifferentGroups_Should_LimitPerGroup(string? group1, string? group2, bool expectLimited)
|
||||
{
|
||||
// arrange
|
||||
var data = JsonSerializer.Serialize(new TestObject { });
|
||||
var client1 = new TestRestClient(x =>
|
||||
{
|
||||
x.RateLimitGroup = group1;
|
||||
});
|
||||
client1.ApiClient1.SetNextResponse(data, System.Net.HttpStatusCode.OK);
|
||||
var client2 = new TestRestClient(x =>
|
||||
{
|
||||
x.RateLimitGroup = group2;
|
||||
});
|
||||
client2.ApiClient1.SetNextResponse(data, System.Net.HttpStatusCode.OK);
|
||||
|
||||
var rateLimiter = new RateLimitGate("Test");
|
||||
rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Request), 1, TimeSpan.FromSeconds(2), RateLimitWindowType.Fixed));
|
||||
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
// act
|
||||
var result1 = await client1.ApiClient1.GetResponseAsync<TestObject>(rateLimitGate: rateLimiter);
|
||||
var result2 = await client2.ApiClient1.GetResponseAsync<TestObject>(rateLimitGate: rateLimiter);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt != null, Is.EqualTo(expectLimited));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class SharedQuantityTests
|
||||
{
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_AllNull_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(null, null, null);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_AllZero_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(0, 0, 0);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_BaseAssetSet_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(1.5m, null, null);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_QuoteAssetSet_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(null, 100m, null);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_ContractsSet_Should_ReturnFalse()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(null, null, 10m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantityReference_IsZero_NegativeValue_Should_ReturnTrue()
|
||||
{
|
||||
// arrange
|
||||
var quantity = new SharedOrderQuantity(-1m, 0, 0);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_DefaultConstructor_Should_SetAllPropertiesToNull()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = new SharedQuantity();
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Base_Should_SetBaseAssetQuantity()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 1.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Base(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedQuantity));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Base_WithZero_Should_SetZeroQuantity()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = SharedQuantity.Base(0m);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(0m));
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Base_WithLargeValue_Should_SetCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var largeValue = 999999.123456789m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Base(largeValue);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(largeValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Quote_Should_SetQuoteAssetQuantity()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 100m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Quote(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(expectedQuantity));
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Quote_WithDecimal_Should_PreserveDecimals()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 50.123456m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Quote(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(expectedQuantity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Contracts_Should_SetContractQuantity()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 10m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Contracts(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(expectedQuantity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_Contracts_WithFractionalValue_Should_SetCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var expectedQuantity = 2.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.Contracts(expectedQuantity);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(expectedQuantity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 50m;
|
||||
var expectedBase = 2m; // 100 / 50 = 2
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedBase));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 3m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 100 / 3 = 33.333... should round to 33.33
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(33.33m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 7m;
|
||||
var lotSize = 0.1m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 100 / 7 = 14.285714... should adjust to nearest 0.1 = 14.3
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(14.3m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithHighPrecision_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var price = 0.00001m;
|
||||
var decimalPlaces = 8;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 2m;
|
||||
var price = 50m;
|
||||
var expectedQuote = 100m; // 2 * 50 = 100
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedQuote));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 1.234567m;
|
||||
var price = 10m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 1.234567 * 10 = 12.34567 should round to 12.35
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(12.35m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 3.456m;
|
||||
var price = 10m;
|
||||
var lotSize = 1m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 3.456 * 10 = 34.56 should adjust to nearest 1 = 35
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(35m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithSmallValues_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 0.001m;
|
||||
var price = 0.1m;
|
||||
var decimalPlaces = 8;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(0.0001m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 10m;
|
||||
var expectedContracts = 10m; // 100 / 10 = 10
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedContracts));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 3m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 100 / 3 = 33.333... should round to 33.33
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(33.33m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 7m;
|
||||
var lotSize = 0.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 100 / 7 = 14.285714... should adjust to nearest 0.5 = 14.5
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(14.5m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithFractionalContract_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 1m;
|
||||
var contractSize = 0.1m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(10m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var contractSize = 10m;
|
||||
var price = 50m;
|
||||
var expectedContracts = 2m; // 1000 / 50 / 10 = 2
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(expectedContracts));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithCustomDecimals_Should_RoundCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var contractSize = 3m;
|
||||
var price = 7m;
|
||||
var decimalPlaces = 2;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 100 / 7 / 3 = 4.761904... should round to 4.76
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(4.76m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithLotSize_Should_AdjustToLotSize()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var contractSize = 7m;
|
||||
var price = 13m;
|
||||
var lotSize = 0.5m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, 8, lotSize);
|
||||
|
||||
// assert
|
||||
// 1000 / 13 / 7 = 10.989... should adjust to nearest 0.5 = 11.0
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(11.0m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithComplexValues_Should_CalculateCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 5000m;
|
||||
var contractSize = 0.01m;
|
||||
var price = 25000m;
|
||||
var decimalPlaces = 4;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price, decimalPlaces);
|
||||
|
||||
// assert
|
||||
// 5000 / 25000 / 0.01 = 20
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(20m));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_DefaultConstructor_Should_SetAllPropertiesToNull()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = new SharedOrderQuantity();
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetBaseAsset()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = 5m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(baseAssetQuantity: baseAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetQuoteAsset()
|
||||
{
|
||||
// arrange
|
||||
var quoteAsset = 100m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(quoteAssetQuantity: quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetContracts()
|
||||
{
|
||||
// arrange
|
||||
var contracts = 10m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(contractQuantity: contracts);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(contracts));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_Should_SetAllValues()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = 1m;
|
||||
var quoteAsset = 50m;
|
||||
var contracts = 5m;
|
||||
|
||||
// act
|
||||
var quantity = new SharedOrderQuantity(baseAsset, quoteAsset, contracts);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(quantity.QuantityInContracts, Is.EqualTo(contracts));
|
||||
Assert.That(quantity.IsZero, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_ParameterizedConstructor_WithNullValues_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange & act
|
||||
var quantity = new SharedOrderQuantity(null, null, null);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInQuoteAsset, Is.Null);
|
||||
Assert.That(quantity.QuantityInContracts, Is.Null);
|
||||
Assert.That(quantity.IsZero, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_RecordEquality_SameValues_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = SharedQuantity.Base(10m);
|
||||
var quantity2 = SharedQuantity.Base(10m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_RecordEquality_DifferentValues_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = SharedQuantity.Base(10m);
|
||||
var quantity2 = SharedQuantity.Base(20m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.Not.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_RecordEquality_DifferentTypes_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = SharedQuantity.Base(10m);
|
||||
var quantity2 = SharedQuantity.Quote(10m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.Not.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedOrderQuantity_RecordEquality_SameValues_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var quantity1 = new SharedOrderQuantity(5m, 100m, 2m);
|
||||
var quantity2 = new SharedOrderQuantity(5m, 100m, 2m);
|
||||
|
||||
// act & assert
|
||||
Assert.That(quantity1, Is.EqualTo(quantity2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_BaseFromQuote_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 100m;
|
||||
var price = 3m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.BaseFromQuote(quoteQuantity, price);
|
||||
|
||||
// assert
|
||||
// Default decimalPlaces = 8, default lotSize = 0.00000001
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_QuoteFromBase_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 1.234567m;
|
||||
var price = 10m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.QuoteFromBase(baseQuantity, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromBase_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var baseQuantity = 100m;
|
||||
var contractSize = 3m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromBase(baseQuantity, contractSize);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedQuantity_ContractsFromQuote_WithDefaultParameters_Should_UseDefaults()
|
||||
{
|
||||
// arrange
|
||||
var quoteQuantity = 1000m;
|
||||
var contractSize = 10m;
|
||||
var price = 50m;
|
||||
|
||||
// act
|
||||
var quantity = SharedQuantity.ContractsFromQuote(quoteQuantity, contractSize, price);
|
||||
|
||||
// assert
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.Not.Null);
|
||||
Assert.That(quantity.QuantityInBaseAsset, Is.GreaterThan(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class SharedSymbolTests
|
||||
{
|
||||
[Test]
|
||||
public void SharedSymbol_Constructor_Should_SetAllProperties()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(symbol.DeliverTime, Is.Null);
|
||||
Assert.That(symbol.SymbolName, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_Constructor_WithDeliveryTime_Should_SetDeliveryTime()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.DeliveryLinear;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
var deliveryTime = new DateTime(2026, 6, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, deliveryTime);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(symbol.DeliverTime, Is.EqualTo(deliveryTime));
|
||||
Assert.That(symbol.SymbolName, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_Constructor_WithNullDeliveryTime_Should_SetToNull()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "ETH";
|
||||
var quoteAsset = "BTC";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, deliverTime: null);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.DeliverTime, Is.Null);
|
||||
}
|
||||
|
||||
[TestCase(TradingMode.Spot)]
|
||||
[TestCase(TradingMode.PerpetualLinear)]
|
||||
[TestCase(TradingMode.PerpetualInverse)]
|
||||
[TestCase(TradingMode.DeliveryLinear)]
|
||||
[TestCase(TradingMode.DeliveryInverse)]
|
||||
public void SharedSymbol_Constructor_WithDifferentTradingModes_Should_SetCorrectly(TradingMode tradingMode)
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_ConstructorWithSymbolName_Should_SetSymbolName()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
var symbolName = "BTC-USDT";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(tradingMode));
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo(symbolName));
|
||||
Assert.That(symbol.DeliverTime, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_ConstructorWithSymbolName_WithCustomFormat_Should_SetCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.PerpetualLinear;
|
||||
var baseAsset = "ETH";
|
||||
var quoteAsset = "USDT";
|
||||
var symbolName = "ETHUSDT-PERP";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo(symbolName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_ConstructorWithSymbolName_WithEmptyString_Should_SetEmptyString()
|
||||
{
|
||||
// arrange
|
||||
var tradingMode = TradingMode.Spot;
|
||||
var baseAsset = "BTC";
|
||||
var quoteAsset = "USDT";
|
||||
var symbolName = "";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, symbolName);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo(""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithSymbolNameSet_Should_ReturnSymbolName()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "CUSTOM-BTC-USDT");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("CUSTOM-BTC-USDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithSymbolNameNull_Should_UseFormatFunction()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}/{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTC/USDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithComplexFormatFunction_Should_ApplyCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.PerpetualLinear, "ETH", "USDT");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => t == TradingMode.PerpetualLinear ? $"{b}{q}-PERP" : $"{b}{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("ETHUSDT-PERP"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithDeliveryTime_Should_PassDeliveryTimeToFormatter()
|
||||
{
|
||||
// arrange
|
||||
var deliveryTime = new DateTime(2026, 6, 25);
|
||||
var symbol = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime);
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => d.HasValue ? $"{b}{q}_{d.Value:yyyyMMdd}" : $"{b}{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTCUSDT_20260625"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithTradingMode_Should_PassTradingModeToFormatter()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.PerpetualInverse, "BTC", "USD");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) =>
|
||||
{
|
||||
return t switch
|
||||
{
|
||||
TradingMode.Spot => $"{b}{q}",
|
||||
TradingMode.PerpetualLinear => $"{b}{q}-PERP",
|
||||
TradingMode.PerpetualInverse => $"{b}{q}I-PERP",
|
||||
_ => $"{b}{q}"
|
||||
};
|
||||
});
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTCUSDI-PERP"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithEmptySymbolName_Should_UseFormatFunction()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}-{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo("BTC-USDT"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetSymbol_WithWhitespaceSymbolName_Should_ReturnWhitespace()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", " ");
|
||||
var formatFunc = new Func<string, string, TradingMode, DateTime?, string>(
|
||||
(b, q, t, d) => $"{b}-{q}");
|
||||
|
||||
// act
|
||||
var result = symbol.GetSymbol(formatFunc);
|
||||
|
||||
// assert
|
||||
Assert.That(result, Is.EqualTo(" "));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_SameValues_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentBaseAsset_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "ETH", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentQuoteAsset_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "EUR");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentTradingMode_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.PerpetualLinear, "BTC", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentDeliveryTime_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var deliveryTime1 = new DateTime(2026, 6, 25);
|
||||
var deliveryTime2 = new DateTime(2026, 9, 25);
|
||||
var symbol1 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime1);
|
||||
var symbol2 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime2);
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_DifferentSymbolName_Should_NotBeEqual()
|
||||
{
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTCUSDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTC-USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_OneWithSymbolNameOneWithout_Should_NotBeEqual()
|
||||
{
|
||||
// NOTE; although this should probably be equal it's considered not because the SymbolName property isn't equal
|
||||
// Overridding equality to ignore SymbolName would be possible but would break the default record equality behavior and cause confusion
|
||||
|
||||
// arrange
|
||||
var symbol1 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT", "BTCUSDT");
|
||||
var symbol2 = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.Not.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_RecordEquality_WithAllPropertiesSet_Should_BeEqual()
|
||||
{
|
||||
// arrange
|
||||
var deliveryTime = new DateTime(2026, 6, 25);
|
||||
var symbol1 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime)
|
||||
{
|
||||
SymbolName = "BTCUSDT-0625"
|
||||
};
|
||||
var symbol2 = new SharedSymbol(TradingMode.DeliveryLinear, "BTC", "USDT", deliveryTime)
|
||||
{
|
||||
SymbolName = "BTCUSDT-0625"
|
||||
};
|
||||
|
||||
// act & assert
|
||||
Assert.That(symbol1, Is.EqualTo(symbol2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_Properties_Should_BeSettable()
|
||||
{
|
||||
// arrange
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, "BTC", "USDT");
|
||||
|
||||
// act
|
||||
symbol.BaseAsset = "ETH";
|
||||
symbol.QuoteAsset = "EUR";
|
||||
symbol.TradingMode = TradingMode.PerpetualLinear;
|
||||
symbol.SymbolName = "CUSTOM";
|
||||
symbol.DeliverTime = DateTime.UtcNow;
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo("ETH"));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo("EUR"));
|
||||
Assert.That(symbol.TradingMode, Is.EqualTo(TradingMode.PerpetualLinear));
|
||||
Assert.That(symbol.SymbolName, Is.EqualTo("CUSTOM"));
|
||||
Assert.That(symbol.DeliverTime, Is.Not.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_WithSpecialCharactersInAssets_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = "BTC-123";
|
||||
var quoteAsset = "USDT_2.0";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedSymbol_WithLongAssetNames_Should_HandleCorrectly()
|
||||
{
|
||||
// arrange
|
||||
var baseAsset = "VERYLONGASSETNAMEFORTESTING";
|
||||
var quoteAsset = "ANOTHERVERYLONGASSETNAME";
|
||||
|
||||
// act
|
||||
var symbol = new SharedSymbol(TradingMode.Spot, baseAsset, quoteAsset);
|
||||
|
||||
// assert
|
||||
Assert.That(symbol.BaseAsset, Is.EqualTo(baseAsset));
|
||||
Assert.That(symbol.QuoteAsset, Is.EqualTo(quoteAsset));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Legacy;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SocketClientTests
|
||||
{
|
||||
[TestCase]
|
||||
public void SettingOptions_Should_ResultInOptionsSet()
|
||||
{
|
||||
//arrange
|
||||
//act
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.SubOptions.ApiCredentials = new Authentication.ApiCredentials("1", "2");
|
||||
options.SubOptions.MaxSocketConnections = 1;
|
||||
});
|
||||
|
||||
//assert
|
||||
ClassicAssert.NotNull(client.SubClient.ApiOptions.ApiCredentials);
|
||||
Assert.That(1 == client.SubClient.ApiOptions.MaxSocketConnections);
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public void ConnectSocket_Should_ReturnConnectionResult(bool canConnect)
|
||||
{
|
||||
//arrange
|
||||
var client = new TestSocketClient();
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = canConnect;
|
||||
|
||||
//act
|
||||
var connectResult = client.SubClient.ConnectSocketSub(new SocketConnection(new TraceLogger(), client.SubClient, socket, null));
|
||||
|
||||
//assert
|
||||
Assert.That(connectResult.Success == canConnect);
|
||||
}
|
||||
|
||||
[TestCase]
|
||||
public void SocketMessages_Should_BeProcessedInDataHandlers()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options => {
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
});
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = true;
|
||||
var sub = new SocketConnection(new TraceLogger(), client.SubClient, socket, null);
|
||||
var rstEvent = new ManualResetEvent(false);
|
||||
Dictionary<string, string> result = null;
|
||||
|
||||
client.SubClient.ConnectSocketSub(sub);
|
||||
|
||||
var subObj = new TestSubscription<Dictionary<string, string>>(Mock.Of<ILogger>(), (messageEvent) =>
|
||||
{
|
||||
result = messageEvent.Data;
|
||||
rstEvent.Set();
|
||||
});
|
||||
sub.AddSubscription(subObj);
|
||||
|
||||
// act
|
||||
socket.InvokeMessage("{\"property\": \"123\", \"action\": \"update\", \"topic\": \"topic\"}");
|
||||
rstEvent.WaitOne(1000);
|
||||
|
||||
// assert
|
||||
Assert.That(result["property"] == "123");
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled)
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
options.SubOptions.OutputOriginalData = enabled;
|
||||
});
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = true;
|
||||
var sub = new SocketConnection(new TraceLogger(), client.SubClient, socket, null);
|
||||
var rstEvent = new ManualResetEvent(false);
|
||||
string original = null;
|
||||
|
||||
client.SubClient.ConnectSocketSub(sub);
|
||||
var subObj = new TestSubscription<Dictionary<string, string>>(Mock.Of<ILogger>(), (messageEvent) =>
|
||||
{
|
||||
original = messageEvent.OriginalData;
|
||||
rstEvent.Set();
|
||||
});
|
||||
sub.AddSubscription(subObj);
|
||||
var msgToSend = JsonSerializer.Serialize(new { topic = "topic", action = "update", property = "123" });
|
||||
|
||||
// act
|
||||
socket.InvokeMessage(msgToSend);
|
||||
rstEvent.WaitOne(1000);
|
||||
|
||||
// assert
|
||||
Assert.That(original == (enabled ? msgToSend : null));
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void UnsubscribingStream_Should_CloseTheSocket()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options =>
|
||||
{
|
||||
options.ReconnectInterval = TimeSpan.Zero;
|
||||
});
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = true;
|
||||
var sub = new SocketConnection(new TraceLogger(), client.SubClient, socket, null);
|
||||
client.SubClient.ConnectSocketSub(sub);
|
||||
|
||||
var subscription = new TestSubscription<Dictionary<string, string>>(Mock.Of<ILogger>(), (messageEvent) => { });
|
||||
var ups = new UpdateSubscription(sub, subscription);
|
||||
sub.AddSubscription(subscription);
|
||||
|
||||
// act
|
||||
client.UnsubscribeAsync(ups).Wait();
|
||||
|
||||
// assert
|
||||
Assert.That(socket.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void UnsubscribingAll_Should_CloseAllSockets()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options => { options.ReconnectInterval = TimeSpan.Zero; });
|
||||
var socket1 = client.CreateSocket();
|
||||
var socket2 = client.CreateSocket();
|
||||
socket1.CanConnect = true;
|
||||
socket2.CanConnect = true;
|
||||
var sub1 = new SocketConnection(new TraceLogger(), client.SubClient, socket1, null);
|
||||
var sub2 = new SocketConnection(new TraceLogger(), client.SubClient, socket2, null);
|
||||
client.SubClient.ConnectSocketSub(sub1);
|
||||
client.SubClient.ConnectSocketSub(sub2);
|
||||
var subscription1 = new TestSubscription<Dictionary<string, string>>(Mock.Of<ILogger>(), (messageEvent) => { });
|
||||
var subscription2 = new TestSubscription<Dictionary<string, string>>(Mock.Of<ILogger>(), (messageEvent) => { });
|
||||
|
||||
sub1.AddSubscription(subscription1);
|
||||
sub2.AddSubscription(subscription2);
|
||||
var ups1 = new UpdateSubscription(sub1, subscription1);
|
||||
var ups2 = new UpdateSubscription(sub2, subscription2);
|
||||
|
||||
// act
|
||||
client.UnsubscribeAllAsync().Wait();
|
||||
|
||||
// assert
|
||||
Assert.That(socket1.Connected == false);
|
||||
Assert.That(socket2.Connected == false);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void FailingToConnectSocket_Should_ReturnError()
|
||||
{
|
||||
// arrange
|
||||
var client = new TestSocketClient(options => { options.ReconnectInterval = TimeSpan.Zero; });
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = false;
|
||||
var sub1 = new SocketConnection(new TraceLogger(), client.SubClient, socket, null);
|
||||
|
||||
// act
|
||||
var connectResult = client.SubClient.ConnectSocketSub(sub1);
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(connectResult.Success);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task ErrorResponse_ShouldNot_ConfirmSubscription()
|
||||
{
|
||||
// arrange
|
||||
var channel = "trade_btcusd";
|
||||
var client = new TestSocketClient(opt =>
|
||||
{
|
||||
opt.OutputOriginalData = true;
|
||||
opt.SocketSubscriptionsCombineTarget = 1;
|
||||
});
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = true;
|
||||
client.SubClient.ConnectSocketSub(new SocketConnection(new TraceLogger(), client.SubClient, socket, "https://test.test"));
|
||||
|
||||
// act
|
||||
var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default);
|
||||
socket.InvokeMessage(JsonSerializer.Serialize(new { channel, action = "subscribe", status = "error" }));
|
||||
await sub;
|
||||
|
||||
// assert
|
||||
ClassicAssert.IsFalse(client.SubClient.TestSubscription.Confirmed);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public async Task SuccessResponse_Should_ConfirmSubscription()
|
||||
{
|
||||
// arrange
|
||||
var channel = "trade_btcusd";
|
||||
var client = new TestSocketClient(opt =>
|
||||
{
|
||||
opt.OutputOriginalData = true;
|
||||
opt.SocketSubscriptionsCombineTarget = 1;
|
||||
});
|
||||
var socket = client.CreateSocket();
|
||||
socket.CanConnect = true;
|
||||
client.SubClient.ConnectSocketSub(new SocketConnection(new TraceLogger(), client.SubClient, socket, "https://test.test"));
|
||||
|
||||
// act
|
||||
var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default);
|
||||
socket.InvokeMessage(JsonSerializer.Serialize(new { channel, action = "subscribe", status = "confirmed" }));
|
||||
await sub;
|
||||
|
||||
// assert
|
||||
Assert.That(client.SubClient.TestSubscription.Confirmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.SocketRoutingTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class QueryRouterTests
|
||||
{
|
||||
[Test]
|
||||
public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType()
|
||||
{
|
||||
// arrange
|
||||
var routes = new MessageRoute[]
|
||||
{
|
||||
MessageRoute<string>.CreateWithoutTopicFilter("type1", (_, _, _, _) => null),
|
||||
MessageRoute<string>.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null),
|
||||
MessageRoute<int>.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null)
|
||||
};
|
||||
|
||||
var router = new QueryRouter(routes);
|
||||
|
||||
// act
|
||||
var type1Routes = router.GetRoutes("type1");
|
||||
var type2Routes = router.GetRoutes("type2");
|
||||
var missingRoutes = router.GetRoutes("missing");
|
||||
|
||||
// assert
|
||||
Assert.That(type1Routes, Is.Not.Null);
|
||||
Assert.That(type2Routes, Is.Not.Null);
|
||||
Assert.That(missingRoutes, Is.Null);
|
||||
|
||||
Assert.That(type1Routes, Is.TypeOf<QueryRouteCollection>());
|
||||
Assert.That(type2Routes, Is.TypeOf<QueryRouteCollection>());
|
||||
Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddRoute_Should_SetMultipleReaders_WhenAnyRouteAllowsMultipleReaders()
|
||||
{
|
||||
// arrange
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
|
||||
// act
|
||||
collection.AddRoute(null, MessageRoute<string>.CreateWithoutTopicFilter("type", (_, _, _, _) => null));
|
||||
var beforeMultipleReaders = collection.MultipleReaders;
|
||||
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) => null, true));
|
||||
var afterMultipleReaders = collection.MultipleReaders;
|
||||
|
||||
// assert
|
||||
Assert.That(beforeMultipleReaders, Is.False);
|
||||
Assert.That(afterMultipleReaders, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute<string>.CreateWithoutTopicFilter("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.Null);
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ReturnFalse_WhenNoRoutesMatch()
|
||||
{
|
||||
// arrange
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("other-topic", MessageRoute<string>.CreateWithTopicFilter("type", "other-topic", (_, _, _, _) => null));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute<string>.CreateWithoutTopicFilter("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.Null);
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_StopAfterFirstNonNullMatchingResult_WhenMultipleReadersIsFalse()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var expectedResult = CallResult.SuccessResult;
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return expectedResult;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return new CallResult(null);
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(expectedResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ContinueAfterNonNullMatchingResult_WhenMultipleReadersIsTrue()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var expectedResult = CallResult.SuccessResult;
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return expectedResult;
|
||||
}, true));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return new CallResult(null);
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(expectedResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first", "second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ContinueUntilNonNullResult_WhenEarlierMatchingRoutesReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var expectedResult = CallResult.SuccessResult;
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return expectedResult;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("third");
|
||||
return new CallResult(null);
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(expectedResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first", "second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ReturnHandledTrue_WhenMatchingRoutesReturnNull()
|
||||
{
|
||||
// arrange
|
||||
var collection = new QueryRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) => null));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using CryptoExchange.Net.Sockets.Interfaces;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.SocketRoutingTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class RoutingTableTests
|
||||
{
|
||||
[Test]
|
||||
public void Update_Should_CreateEntriesPerTypeIdentifier_WithCorrectDeserializationTypeAndHandlers()
|
||||
{
|
||||
// arrange
|
||||
var processor1 = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute<string>.CreateWithoutTopicFilter("type1", (_, _, _, _) => null),
|
||||
MessageRoute<string>.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null)));
|
||||
|
||||
var processor2 = new TestMessageProcessor(
|
||||
2,
|
||||
MessageRouter.Create(
|
||||
MessageRoute<int>.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
|
||||
// act
|
||||
table.Update(new IMessageProcessor[] { processor1, processor2 });
|
||||
|
||||
var type1Entry = table.GetRouteTableEntry("type1");
|
||||
var type2Entry = table.GetRouteTableEntry("type2");
|
||||
var missingEntry = table.GetRouteTableEntry("missing");
|
||||
|
||||
// assert
|
||||
Assert.That(type1Entry, Is.Not.Null);
|
||||
Assert.That(type2Entry, Is.Not.Null);
|
||||
Assert.That(missingEntry, Is.Null);
|
||||
|
||||
Assert.That(type1Entry!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(type1Entry.IsStringOutput, Is.True);
|
||||
Assert.That(type1Entry.Handlers, Has.Count.EqualTo(1));
|
||||
Assert.That(type1Entry.Handlers.Single(), Is.SameAs(processor1));
|
||||
|
||||
Assert.That(type2Entry!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
Assert.That(type2Entry.IsStringOutput, Is.False);
|
||||
Assert.That(type2Entry.Handlers, Has.Count.EqualTo(1));
|
||||
Assert.That(type2Entry.Handlers.Single(), Is.SameAs(processor2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Update_Should_AddMultipleProcessors_ForSameTypeIdentifier()
|
||||
{
|
||||
// arrange
|
||||
var processor1 = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute<string>.CreateWithoutTopicFilter("type1", (_, _, _, _) => null)));
|
||||
|
||||
var processor2 = new TestMessageProcessor(
|
||||
2,
|
||||
MessageRouter.Create(
|
||||
MessageRoute<string>.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
|
||||
// act
|
||||
table.Update(new IMessageProcessor[] { processor1, processor2 });
|
||||
var entry = table.GetRouteTableEntry("type1");
|
||||
|
||||
// assert
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
Assert.That(entry!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(entry.Handlers, Has.Count.EqualTo(2));
|
||||
Assert.That(entry.Handlers, Does.Contain(processor1));
|
||||
Assert.That(entry.Handlers, Does.Contain(processor2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Update_Should_ReplacePreviousEntries()
|
||||
{
|
||||
// arrange
|
||||
var initialProcessor = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute<string>.CreateWithoutTopicFilter("type1", (_, _, _, _) => null)));
|
||||
|
||||
var replacementProcessor = new TestMessageProcessor(
|
||||
2,
|
||||
MessageRouter.Create(
|
||||
MessageRoute<int>.CreateWithoutTopicFilter("type2", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
table.Update(new IMessageProcessor[] { initialProcessor });
|
||||
|
||||
// act
|
||||
table.Update(new IMessageProcessor[] { replacementProcessor });
|
||||
|
||||
var oldEntry = table.GetRouteTableEntry("type1");
|
||||
var newEntry = table.GetRouteTableEntry("type2");
|
||||
|
||||
// assert
|
||||
Assert.That(oldEntry, Is.Null);
|
||||
Assert.That(newEntry, Is.Not.Null);
|
||||
Assert.That(newEntry!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
Assert.That(newEntry.Handlers, Has.Count.EqualTo(1));
|
||||
Assert.That(newEntry.Handlers.Single(), Is.SameAs(replacementProcessor));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Update_WithEmptyProcessors_Should_ClearEntries()
|
||||
{
|
||||
// arrange
|
||||
var processor = new TestMessageProcessor(
|
||||
1,
|
||||
MessageRouter.Create(
|
||||
MessageRoute<string>.CreateWithoutTopicFilter("type1", (_, _, _, _) => null)));
|
||||
|
||||
var table = new RoutingTable();
|
||||
table.Update(new IMessageProcessor[] { processor });
|
||||
|
||||
// act
|
||||
table.Update(Array.Empty<IMessageProcessor>());
|
||||
|
||||
// assert
|
||||
Assert.That(table.GetRouteTableEntry("type1"), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypeRoutingCollection_Should_SetIsStringOutput_BasedOnDeserializationType()
|
||||
{
|
||||
// arrange & act
|
||||
var stringCollection = new TypeRoutingCollection(typeof(string));
|
||||
var intCollection = new TypeRoutingCollection(typeof(int));
|
||||
|
||||
// assert
|
||||
Assert.That(stringCollection.IsStringOutput, Is.True);
|
||||
Assert.That(stringCollection.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(stringCollection.Handlers, Is.Empty);
|
||||
|
||||
Assert.That(intCollection.IsStringOutput, Is.False);
|
||||
Assert.That(intCollection.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
Assert.That(intCollection.Handlers, Is.Empty);
|
||||
}
|
||||
|
||||
private sealed class TestMessageProcessor : IMessageProcessor
|
||||
{
|
||||
public int Id { get; }
|
||||
public MessageRouter MessageRouter { get; }
|
||||
|
||||
public TestMessageProcessor(int id, MessageRouter messageRouter)
|
||||
{
|
||||
Id = id;
|
||||
MessageRouter = messageRouter;
|
||||
}
|
||||
|
||||
public event Action? OnMessageRouterUpdated;
|
||||
|
||||
public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection socketConnection, DateTime receiveTime, string? originalData, object result)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Sockets.Default.Routing;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.SocketRoutingTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SubscriptionRouterTests
|
||||
{
|
||||
[Test]
|
||||
public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType()
|
||||
{
|
||||
// arrange
|
||||
var routes = new MessageRoute[]
|
||||
{
|
||||
MessageRoute<string>.CreateWithoutTopicFilter("type1", (_, _, _, _) => null),
|
||||
MessageRoute<string>.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null),
|
||||
MessageRoute<int>.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null)
|
||||
};
|
||||
|
||||
var router = new SubscriptionRouter(routes);
|
||||
|
||||
// act
|
||||
var type1Routes = router.GetRoutes("type1");
|
||||
var type2Routes = router.GetRoutes("type2");
|
||||
var missingRoutes = router.GetRoutes("missing");
|
||||
|
||||
// assert
|
||||
Assert.That(type1Routes, Is.Not.Null);
|
||||
Assert.That(type2Routes, Is.Not.Null);
|
||||
Assert.That(missingRoutes, Is.Null);
|
||||
|
||||
Assert.That(type1Routes, Is.TypeOf<SubscriptionRouteCollection>());
|
||||
Assert.That(type2Routes, Is.TypeOf<SubscriptionRouteCollection>());
|
||||
Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string)));
|
||||
Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute<string>.CreateWithoutTopicFilter("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(CallResult.SuccessResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_ReturnFalse_WhenNoRoutesMatch()
|
||||
{
|
||||
// arrange
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute("other-topic", MessageRoute<string>.CreateWithTopicFilter("type", "other-topic", (_, _, _, _) => null));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(result, Is.SameAs(CallResult.SuccessResult));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute(null, MessageRoute<string>.CreateWithoutTopicFilter("type", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("no-topic");
|
||||
return null;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(CallResult.SuccessResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_InvokeAllMatchingTopicRoutes()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("first");
|
||||
return CallResult.SuccessResult;
|
||||
}));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("second");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(result, Is.SameAs(CallResult.SuccessResult));
|
||||
Assert.That(calls, Is.EqualTo(new[] { "first", "second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handle_Should_NotInvokeTopicRoutes_WhenTopicFilterIsNull()
|
||||
{
|
||||
// arrange
|
||||
var calls = new List<string>();
|
||||
var collection = new SubscriptionRouteCollection(typeof(string));
|
||||
collection.AddRoute("topic", MessageRoute<string>.CreateWithTopicFilter("type", "topic", (_, _, _, _) =>
|
||||
{
|
||||
calls.Add("topic");
|
||||
return null;
|
||||
}));
|
||||
collection.Build();
|
||||
|
||||
// act
|
||||
var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result);
|
||||
|
||||
// assert
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(result, Is.SameAs(CallResult.SuccessResult));
|
||||
Assert.That(calls, Is.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using NUnit.Framework.Legacy;
|
||||
using CryptoExchange.Net.Converters;
|
||||
using CryptoExchange.Net.Testing.Comparers;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[TestFixture()]
|
||||
public class SystemTextJsonConverterTests
|
||||
{
|
||||
[TestCase("2021-05-12")]
|
||||
[TestCase("20210512")]
|
||||
[TestCase("210512")]
|
||||
[TestCase("1620777600.000")]
|
||||
[TestCase("1620777600000")]
|
||||
[TestCase("2021-05-12T00:00:00.000Z")]
|
||||
[TestCase("2021-05-12T00:00:00.000000000Z")]
|
||||
[TestCase("0.000000", true)]
|
||||
[TestCase("0", true)]
|
||||
[TestCase("", true)]
|
||||
[TestCase(" ", true)]
|
||||
public void TestDateTimeConverterString(string input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": \"{input}\" }}");
|
||||
Assert.That(output.Time == (expectNull ? null: new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[TestCase(1620777600.000)]
|
||||
[TestCase(1620777600000d)]
|
||||
public void TestDateTimeConverterDouble(double input)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.That(output.Time == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000000)]
|
||||
[TestCase(1620777600000000000)]
|
||||
[TestCase(0, true)]
|
||||
public void TestDateTimeConverterLong(long input, bool expectNull = false)
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": {input} }}");
|
||||
Assert.That(output.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[TestCase(1620777600)]
|
||||
[TestCase(1620777600.000)]
|
||||
public void TestDateTimeConverterFromSeconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromSeconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToSeconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000)]
|
||||
[TestCase(1620777600000.000)]
|
||||
public void TestDateTimeConverterFromMilliseconds(double input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMilliseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMilliseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000)]
|
||||
public void TestDateTimeConverterFromMicroseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromMicroseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToMicroseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000000);
|
||||
}
|
||||
|
||||
[TestCase(1620777600000000000)]
|
||||
public void TestDateTimeConverterFromNanoseconds(long input)
|
||||
{
|
||||
var output = DateTimeConverter.ConvertFromNanoseconds(input);
|
||||
Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDateTimeConverterToNanoseconds()
|
||||
{
|
||||
var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||
Assert.That(output == 1620777600000000000);
|
||||
}
|
||||
|
||||
[TestCase()]
|
||||
public void TestDateTimeConverterNull()
|
||||
{
|
||||
var output = JsonSerializer.Deserialize<STJTimeObject>($"{{ \"time\": null }}");
|
||||
Assert.That(output.Time == null);
|
||||
}
|
||||
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.That(output == expected);
|
||||
}
|
||||
|
||||
[TestCase(TestEnum.One, "1")]
|
||||
[TestCase(TestEnum.Two, "2")]
|
||||
[TestCase(TestEnum.Three, "three")]
|
||||
[TestCase(TestEnum.Four, "Four")]
|
||||
public void TestEnumConverterGetStringTests(TestEnum value, string expected)
|
||||
{
|
||||
var output = EnumConverter.GetString(value);
|
||||
Assert.That(output == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", null)]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<STJEnumObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext()));
|
||||
Assert.That(output.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", TestEnum.One)]
|
||||
[TestCase(null, TestEnum.One)]
|
||||
public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<NotNullableSTJEnumObject>($"{{ \"Value\": {val} }}");
|
||||
Assert.That(output.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", TestEnum.One)]
|
||||
[TestCase("2", TestEnum.Two)]
|
||||
[TestCase("3", TestEnum.Three)]
|
||||
[TestCase("three", TestEnum.Three)]
|
||||
[TestCase("Four", TestEnum.Four)]
|
||||
[TestCase("four", TestEnum.Four)]
|
||||
[TestCase("Four1", null)]
|
||||
[TestCase(null, null)]
|
||||
public void TestEnumConverterParseStringTests(string value, TestEnum? expected)
|
||||
{
|
||||
var result = EnumConverter.ParseString<TestEnum>(value);
|
||||
Assert.That(result == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", true)]
|
||||
[TestCase("true", true)]
|
||||
[TestCase("yes", true)]
|
||||
[TestCase("y", true)]
|
||||
[TestCase("on", true)]
|
||||
[TestCase("-1", false)]
|
||||
[TestCase("0", false)]
|
||||
[TestCase("n", false)]
|
||||
[TestCase("no", false)]
|
||||
[TestCase("false", false)]
|
||||
[TestCase("off", false)]
|
||||
[TestCase("", null)]
|
||||
public void TestBoolConverter(string value, bool? expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<STJBoolObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext()));
|
||||
Assert.That(output.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", true)]
|
||||
[TestCase("true", true)]
|
||||
[TestCase("yes", true)]
|
||||
[TestCase("y", true)]
|
||||
[TestCase("on", true)]
|
||||
[TestCase("-1", false)]
|
||||
[TestCase("0", false)]
|
||||
[TestCase("n", false)]
|
||||
[TestCase("no", false)]
|
||||
[TestCase("false", false)]
|
||||
[TestCase("off", false)]
|
||||
[TestCase("", false)]
|
||||
public void TestBoolConverterNotNullable(string value, bool expected)
|
||||
{
|
||||
var val = value == null ? "null" : $"\"{value}\"";
|
||||
var output = JsonSerializer.Deserialize<NotNullableSTJBoolObject>($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext()));
|
||||
Assert.That(output.Value == expected);
|
||||
}
|
||||
|
||||
[TestCase("1", 1)]
|
||||
[TestCase("1.1", 1.1)]
|
||||
[TestCase("-1.1", -1.1)]
|
||||
[TestCase(null, null)]
|
||||
[TestCase("", null)]
|
||||
[TestCase("null", null)]
|
||||
[TestCase("nan", null)]
|
||||
[TestCase("1E+2", 100)]
|
||||
[TestCase("1E-2", 0.01)]
|
||||
[TestCase("Infinity", 999)] // 999 is workaround for not being able to specify decimal.MinValue
|
||||
[TestCase("-Infinity", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
[TestCase("80228162514264337593543950335", 999)] // 999 is workaround for not being able to specify decimal.MaxValue
|
||||
[TestCase("-80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
public void TestDecimalConverterString(string value, decimal? expected)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<STJDecimalObject>("{ \"test\": \""+ value + "\"}");
|
||||
Assert.That(result.Test, Is.EqualTo(expected == -999 ? decimal.MinValue : expected == 999 ? decimal.MaxValue: expected));
|
||||
}
|
||||
|
||||
[TestCase("1", 1)]
|
||||
[TestCase("1.1", 1.1)]
|
||||
[TestCase("-1.1", -1.1)]
|
||||
[TestCase("null", null)]
|
||||
[TestCase("1E+2", 100)]
|
||||
[TestCase("1E-2", 0.01)]
|
||||
[TestCase("80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue
|
||||
public void TestDecimalConverterNumber(string value, decimal? expected)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<STJDecimalObject>("{ \"test\": " + value + "}");
|
||||
Assert.That(result.Test, Is.EqualTo(expected == -999 ? decimal.MaxValue : expected));
|
||||
}
|
||||
|
||||
[Test()]
|
||||
public void TestArrayConverter()
|
||||
{
|
||||
var data = new Test()
|
||||
{
|
||||
Prop1 = 2,
|
||||
Prop2 = null,
|
||||
Prop3 = "123",
|
||||
Prop3Again = "123",
|
||||
Prop4 = null,
|
||||
Prop5 = new Test2
|
||||
{
|
||||
Prop21 = 3,
|
||||
Prop22 = "456"
|
||||
},
|
||||
Prop6 = new Test3
|
||||
{
|
||||
Prop31 = 4,
|
||||
Prop32 = "789"
|
||||
},
|
||||
Prop7 = TestEnum.Two,
|
||||
TestInternal = new Test
|
||||
{
|
||||
Prop1 = 10
|
||||
},
|
||||
Prop8 = new Test3
|
||||
{
|
||||
Prop31 = 5,
|
||||
Prop32 = "101"
|
||||
},
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions()
|
||||
{
|
||||
TypeInfoResolver = new SerializationContext()
|
||||
};
|
||||
var serialized = JsonSerializer.Serialize(data);
|
||||
var deserialized = JsonSerializer.Deserialize<Test>(serialized);
|
||||
|
||||
Assert.That(deserialized.Prop1, Is.EqualTo(2));
|
||||
Assert.That(deserialized.Prop2, Is.Null);
|
||||
Assert.That(deserialized.Prop3, Is.EqualTo("123"));
|
||||
Assert.That(deserialized.Prop3Again, Is.EqualTo("123"));
|
||||
Assert.That(deserialized.Prop4, Is.Null);
|
||||
Assert.That(deserialized.Prop5.Prop21, Is.EqualTo(3));
|
||||
Assert.That(deserialized.Prop5.Prop22, Is.EqualTo("456"));
|
||||
Assert.That(deserialized.Prop6.Prop31, Is.EqualTo(4));
|
||||
Assert.That(deserialized.Prop6.Prop32, Is.EqualTo("789"));
|
||||
Assert.That(deserialized.Prop7, Is.EqualTo(TestEnum.Two));
|
||||
Assert.That(deserialized.TestInternal.Prop1, Is.EqualTo(10));
|
||||
Assert.That(deserialized.Prop8.Prop31, Is.EqualTo(5));
|
||||
Assert.That(deserialized.Prop8.Prop32, Is.EqualTo("101"));
|
||||
}
|
||||
|
||||
[TestCase(TradingMode.Spot, "ETH", "USDT", null)]
|
||||
[TestCase(TradingMode.PerpetualLinear, "ETH", "USDT", null)]
|
||||
[TestCase(TradingMode.DeliveryLinear, "ETH", "USDT", 1748432430)]
|
||||
public void TestSharedSymbolConversion(TradingMode tradingMode, string baseAsset, string quoteAsset, int? deliverTime)
|
||||
{
|
||||
DateTime? time = deliverTime == null ? null : DateTimeConverter.ParseFromDouble(deliverTime.Value);
|
||||
var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, time);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(symbol);
|
||||
var restored = JsonSerializer.Deserialize<SharedSymbol>(serialized);
|
||||
|
||||
Assert.That(restored.TradingMode, Is.EqualTo(symbol.TradingMode));
|
||||
Assert.That(restored.BaseAsset, Is.EqualTo(symbol.BaseAsset));
|
||||
Assert.That(restored.QuoteAsset, Is.EqualTo(symbol.QuoteAsset));
|
||||
Assert.That(restored.DeliverTime, Is.EqualTo(symbol.DeliverTime));
|
||||
}
|
||||
|
||||
[TestCase(0.1, null, null)]
|
||||
[TestCase(0.1, 0.1, null)]
|
||||
[TestCase(0.1, 0.1, 0.1)]
|
||||
[TestCase(null, 0.1, null)]
|
||||
[TestCase(null, 0.1, 0.1)]
|
||||
public void TestSharedQuantityConversion(double? baseQuantity, double? quoteQuantity, double? contractQuantity)
|
||||
{
|
||||
var symbol = new SharedOrderQuantity((decimal?)baseQuantity, (decimal?)quoteQuantity, (decimal?)contractQuantity);
|
||||
|
||||
var serialized = JsonSerializer.Serialize(symbol);
|
||||
var restored = JsonSerializer.Deserialize<SharedOrderQuantity>(serialized);
|
||||
|
||||
Assert.That(restored.QuantityInBaseAsset, Is.EqualTo(symbol.QuantityInBaseAsset));
|
||||
Assert.That(restored.QuantityInQuoteAsset, Is.EqualTo(symbol.QuantityInQuoteAsset));
|
||||
Assert.That(restored.QuantityInContracts, Is.EqualTo(symbol.QuantityInContracts));
|
||||
}
|
||||
}
|
||||
|
||||
public class STJDecimalObject
|
||||
{
|
||||
[JsonConverter(typeof(DecimalConverter))]
|
||||
[JsonPropertyName("test")]
|
||||
public decimal? Test { get; set; }
|
||||
}
|
||||
|
||||
public class STJTimeObject
|
||||
{
|
||||
[JsonConverter(typeof(DateTimeConverter))]
|
||||
[JsonPropertyName("time")]
|
||||
public DateTime? Time { get; set; }
|
||||
}
|
||||
|
||||
public class STJEnumObject
|
||||
{
|
||||
public TestEnum? Value { get; set; }
|
||||
}
|
||||
|
||||
public class NotNullableSTJEnumObject
|
||||
{
|
||||
public TestEnum Value { get; set; }
|
||||
}
|
||||
|
||||
public class STJBoolObject
|
||||
{
|
||||
public bool? Value { get; set; }
|
||||
}
|
||||
|
||||
public class NotNullableSTJBoolObject
|
||||
{
|
||||
public bool Value { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ArrayConverter<Test>))]
|
||||
record Test
|
||||
{
|
||||
[ArrayProperty(0)]
|
||||
public int Prop1 { get; set; }
|
||||
[ArrayProperty(1)]
|
||||
public int? Prop2 { get; set; }
|
||||
[ArrayProperty(2)]
|
||||
public string Prop3 { get; set; }
|
||||
[ArrayProperty(2)]
|
||||
public string Prop3Again { get; set; }
|
||||
[ArrayProperty(3)]
|
||||
public string Prop4 { get; set; }
|
||||
[ArrayProperty(4)]
|
||||
public Test2 Prop5 { get; set; }
|
||||
[ArrayProperty(5)]
|
||||
public Test3 Prop6 { get; set; }
|
||||
[ArrayProperty(6), JsonConverter(typeof(EnumConverter<TestEnum>))]
|
||||
public TestEnum? Prop7 { get; set; }
|
||||
[ArrayProperty(7)]
|
||||
public Test TestInternal { get; set; }
|
||||
[ArrayProperty(8), JsonConversion]
|
||||
public Test3 Prop8 { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ArrayConverter<Test2>))]
|
||||
record Test2
|
||||
{
|
||||
[ArrayProperty(0)]
|
||||
public int Prop21 { get; set; }
|
||||
[ArrayProperty(1)]
|
||||
public string Prop22 { get; set; }
|
||||
}
|
||||
|
||||
record Test3
|
||||
{
|
||||
[JsonPropertyName("prop31")]
|
||||
public int Prop31 { get; set; }
|
||||
[JsonPropertyName("prop32")]
|
||||
public string Prop32 { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(EnumConverter<TestEnum>))]
|
||||
public enum TestEnum
|
||||
{
|
||||
[Map("1")]
|
||||
One,
|
||||
[Map("2")]
|
||||
Two,
|
||||
[Map("three", "3")]
|
||||
Three,
|
||||
Four
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(Test))]
|
||||
[JsonSerializable(typeof(Test2))]
|
||||
[JsonSerializable(typeof(Test3))]
|
||||
[JsonSerializable(typeof(NotNullableSTJBoolObject))]
|
||||
[JsonSerializable(typeof(STJBoolObject))]
|
||||
[JsonSerializable(typeof(NotNullableSTJEnumObject))]
|
||||
[JsonSerializable(typeof(STJEnumObject))]
|
||||
[JsonSerializable(typeof(STJDecimalObject))]
|
||||
[JsonSerializable(typeof(STJTimeObject))]
|
||||
internal partial class SerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
|
||||
{
|
||||
internal class SubResponse
|
||||
{
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("channel")]
|
||||
public string Channel { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = null!;
|
||||
}
|
||||
|
||||
internal class UnsubResponse
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = null!;
|
||||
}
|
||||
|
||||
internal class TestChannelQuery : Query<SubResponse>
|
||||
{
|
||||
public TestChannelQuery(string channel, string request, bool authenticated, int weight = 1) : base(request, authenticated, weight)
|
||||
{
|
||||
MessageMatcher = MessageMatcher.Create<SubResponse>(request + "-" + channel, HandleMessage);
|
||||
}
|
||||
|
||||
public CallResult<SubResponse> HandleMessage(SocketConnection connection, DataEvent<SubResponse> message)
|
||||
{
|
||||
if (!message.Data.Status.Equals("confirmed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new CallResult<SubResponse>(new ServerError(message.Data.Status));
|
||||
}
|
||||
|
||||
return message.ToCallResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
|
||||
{
|
||||
internal class TestQuery : Query<object>
|
||||
{
|
||||
public TestQuery(string identifier, object request, bool authenticated, int weight = 1) : base(request, authenticated, weight)
|
||||
{
|
||||
MessageMatcher = MessageMatcher.Create<object>(identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
|
||||
{
|
||||
internal class TestSubscription<T> : Subscription<object, object>
|
||||
{
|
||||
private readonly Action<DataEvent<T>> _handler;
|
||||
|
||||
public TestSubscription(ILogger logger, Action<DataEvent<T>> handler) : base(logger, false)
|
||||
{
|
||||
_handler = handler;
|
||||
|
||||
MessageMatcher = MessageMatcher.Create<T>("update-topic", DoHandleMessage);
|
||||
}
|
||||
|
||||
public CallResult DoHandleMessage(SocketConnection connection, DataEvent<T> message)
|
||||
{
|
||||
_handler.Invoke(message);
|
||||
return new CallResult(null);
|
||||
}
|
||||
|
||||
public override Query GetSubQuery(SocketConnection connection) => new TestQuery("sub", new object(), false, 1);
|
||||
public override Query GetUnsubQuery() => new TestQuery("unsub", new object(), false, 1);
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets
|
||||
{
|
||||
internal class TestSubscriptionWithResponseCheck<T> : Subscription<SubResponse, UnsubResponse>
|
||||
{
|
||||
private readonly Action<DataEvent<T>> _handler;
|
||||
private readonly string _channel;
|
||||
|
||||
public TestSubscriptionWithResponseCheck(string channel, Action<DataEvent<T>> handler) : base(Mock.Of<ILogger>(), false)
|
||||
{
|
||||
MessageMatcher = MessageMatcher.Create<T>(channel, DoHandleMessage);
|
||||
_handler = handler;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public CallResult DoHandleMessage(SocketConnection connection, DataEvent<T> message)
|
||||
{
|
||||
_handler.Invoke(message);
|
||||
return new CallResult(null);
|
||||
}
|
||||
|
||||
public override Query GetSubQuery(SocketConnection connection) => new TestChannelQuery(_channel, "subscribe", false, 1);
|
||||
public override Query GetUnsubQuery() => new TestChannelQuery(_channel, "unsubscribe", false, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
public class TestBaseClient: BaseClient
|
||||
{
|
||||
public TestSubClient SubClient { get; }
|
||||
|
||||
public TestBaseClient(): base(null, "Test")
|
||||
{
|
||||
var options = new TestClientOptions();
|
||||
_logger = NullLogger.Instance;
|
||||
Initialize(options);
|
||||
SubClient = AddApiClient(new TestSubClient(options, new RestApiOptions()));
|
||||
}
|
||||
|
||||
public TestBaseClient(TestClientOptions exchangeOptions) : base(null, "Test")
|
||||
{
|
||||
_logger = NullLogger.Instance;
|
||||
Initialize(exchangeOptions);
|
||||
SubClient = AddApiClient(new TestSubClient(exchangeOptions, new RestApiOptions()));
|
||||
}
|
||||
|
||||
public void Log(LogLevel verbosity, string data)
|
||||
{
|
||||
_logger.Log(verbosity, data);
|
||||
}
|
||||
}
|
||||
|
||||
public class TestSubClient : RestApiClient
|
||||
{
|
||||
public TestSubClient(RestExchangeOptions<TestEnvironment> options, RestApiOptions apiOptions) : base(new TraceLogger(), null, "https://localhost:123", options, apiOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public CallResult<T> Deserialize<T>(string data)
|
||||
{
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(data));
|
||||
var accessor = CreateAccessor();
|
||||
var valid = accessor.Read(stream, true).Result;
|
||||
if (!valid)
|
||||
return new CallResult<T>(new ServerError(data));
|
||||
|
||||
var deserializeResult = accessor.Deserialize<T>();
|
||||
return deserializeResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
|
||||
public override TimeSpan? GetTimeOffset() => null;
|
||||
public override TimeSyncInfo GetTimeSyncInfo() => null;
|
||||
protected override IStreamMessageAccessor CreateAccessor() => new SystemTextJsonStreamMessageAccessor(new System.Text.Json.JsonSerializerOptions());
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions());
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials) => throw new NotImplementedException();
|
||||
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public class TestAuthProvider : AuthenticationProvider
|
||||
{
|
||||
public TestAuthProvider(ApiCredentials credentials) : base(credentials)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AuthenticateRequest(RestApiClient apiClient, Uri uri, HttpMethod method, ref IDictionary<string, object> uriParams, ref IDictionary<string, object> bodyParams, ref Dictionary<string, string> headers, bool auth, ArrayParametersSerialization arraySerialization, HttpMethodParameterPosition parameterPosition, RequestBodyFormat bodyFormat)
|
||||
{
|
||||
}
|
||||
|
||||
public string GetKey() => _credentials.Key;
|
||||
public string GetSecret() => _credentials.Secret;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestHelpers
|
||||
{
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static bool AreEqual<T>(T self, T to, params string[] ignore) where T : class
|
||||
{
|
||||
if (self != null && to != null)
|
||||
{
|
||||
var type = self.GetType();
|
||||
var ignoreList = new List<string>(ignore);
|
||||
foreach (var pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (ignoreList.Contains(pi.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var selfValue = type.GetProperty(pi.Name).GetValue(self, null);
|
||||
var toValue = type.GetProperty(pi.Name).GetValue(to, null);
|
||||
|
||||
if (pi.PropertyType.IsClass && !pi.PropertyType.Module.ScopeName.Equals("System.Private.CoreLib.dll"))
|
||||
{
|
||||
// Check of "CommonLanguageRuntimeLibrary" is needed because string is also a class
|
||||
if (AreEqual(selfValue, toValue, ignore))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selfValue != toValue && (selfValue == null || !selfValue.Equals(toValue)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return self == to;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Linq;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
public class TestRestClient: BaseRestClient
|
||||
{
|
||||
public TestRestApi1Client Api1 { get; }
|
||||
public TestRestApi2Client Api2 { get; }
|
||||
|
||||
public TestRestClient(Action<TestClientOptions> optionsDelegate = null)
|
||||
: this(null, null, Options.Create(ApplyOptionsDelegate(optionsDelegate)))
|
||||
{
|
||||
}
|
||||
|
||||
public TestRestClient(HttpClient httpClient, ILoggerFactory loggerFactory, IOptions<TestClientOptions> options) : base(loggerFactory, "Test")
|
||||
{
|
||||
Initialize(options.Value);
|
||||
|
||||
Api1 = new TestRestApi1Client(options.Value);
|
||||
Api2 = new TestRestApi2Client(options.Value);
|
||||
}
|
||||
|
||||
public void SetResponse(string responseData, out IRequest requestObj)
|
||||
{
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(responseData);
|
||||
var responseStream = new MemoryStream();
|
||||
responseStream.Write(expectedBytes, 0, expectedBytes.Length);
|
||||
responseStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var response = new Mock<IResponse>();
|
||||
response.Setup(c => c.IsSuccessStatusCode).Returns(true);
|
||||
response.Setup(c => c.GetResponseStreamAsync()).Returns(Task.FromResult((Stream)responseStream));
|
||||
|
||||
var headers = new Dictionary<string, string[]>();
|
||||
var request = new Mock<IRequest>();
|
||||
request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com"));
|
||||
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Returns(Task.FromResult(response.Object));
|
||||
request.Setup(c => c.SetContent(It.IsAny<string>(), It.IsAny<string>())).Callback(new Action<string, string>((content, type) => { request.Setup(r => r.Content).Returns(content); }));
|
||||
request.Setup(c => c.AddHeader(It.IsAny<string>(), It.IsAny<string>())).Callback<string, string>((key, val) => headers.Add(key, new string[] { val }));
|
||||
request.Setup(c => c.GetHeaders()).Returns(() => headers.ToArray());
|
||||
|
||||
var factory = Mock.Get(Api1.RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Callback<HttpMethod, Uri, int>((method, uri, id) =>
|
||||
{
|
||||
request.Setup(a => a.Uri).Returns(uri);
|
||||
request.Setup(a => a.Method).Returns(method);
|
||||
})
|
||||
.Returns(request.Object);
|
||||
|
||||
factory = Mock.Get(Api2.RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Callback<HttpMethod, Uri, int>((method, uri, id) =>
|
||||
{
|
||||
request.Setup(a => a.Uri).Returns(uri);
|
||||
request.Setup(a => a.Method).Returns(method);
|
||||
})
|
||||
.Returns(request.Object);
|
||||
requestObj = request.Object;
|
||||
}
|
||||
|
||||
public void SetErrorWithoutResponse(HttpStatusCode code, string message)
|
||||
{
|
||||
var we = new HttpRequestException();
|
||||
typeof(HttpRequestException).GetField("_message", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).SetValue(we, message);
|
||||
|
||||
var request = new Mock<IRequest>();
|
||||
request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com"));
|
||||
request.Setup(c => c.GetHeaders()).Returns(new KeyValuePair<string, string[]>[0]);
|
||||
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Throws(we);
|
||||
|
||||
var factory = Mock.Get(Api1.RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Returns(request.Object);
|
||||
|
||||
|
||||
factory = Mock.Get(Api2.RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Returns(request.Object);
|
||||
}
|
||||
|
||||
public void SetErrorWithResponse(string responseData, HttpStatusCode code)
|
||||
{
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(responseData);
|
||||
var responseStream = new MemoryStream();
|
||||
responseStream.Write(expectedBytes, 0, expectedBytes.Length);
|
||||
responseStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var response = new Mock<IResponse>();
|
||||
response.Setup(c => c.IsSuccessStatusCode).Returns(false);
|
||||
response.Setup(c => c.GetResponseStreamAsync()).Returns(Task.FromResult((Stream)responseStream));
|
||||
|
||||
var headers = new List<KeyValuePair<string, string[]>>();
|
||||
var request = new Mock<IRequest>();
|
||||
request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com"));
|
||||
request.Setup(c => c.GetResponseAsync(It.IsAny<CancellationToken>())).Returns(Task.FromResult(response.Object));
|
||||
request.Setup(c => c.AddHeader(It.IsAny<string>(), It.IsAny<string>())).Callback<string, string>((key, val) => headers.Add(new KeyValuePair<string, string[]>(key, new string[] { val })));
|
||||
request.Setup(c => c.GetHeaders()).Returns(headers.ToArray());
|
||||
|
||||
var factory = Mock.Get(Api1.RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Callback<HttpMethod, Uri, int>((method, uri, id) => request.Setup(a => a.Uri).Returns(uri))
|
||||
.Returns(request.Object);
|
||||
|
||||
factory = Mock.Get(Api2.RequestFactory);
|
||||
factory.Setup(c => c.Create(It.IsAny<HttpMethod>(), It.IsAny<Uri>(), It.IsAny<int>()))
|
||||
.Callback<HttpMethod, Uri, int>((method, uri, id) => request.Setup(a => a.Uri).Returns(uri))
|
||||
.Returns(request.Object);
|
||||
}
|
||||
}
|
||||
|
||||
public class TestRestApi1Client : RestApiClient
|
||||
{
|
||||
public TestRestApi1Client(TestClientOptions options) : base(new TraceLogger(), null, "https://localhost:123", options, options.Api1Options)
|
||||
{
|
||||
RequestFactory = new Mock<IRequestFactory>().Object;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
|
||||
|
||||
protected override IStreamMessageAccessor CreateAccessor() => new SystemTextJsonStreamMessageAccessor(new System.Text.Json.JsonSerializerOptions() { TypeInfoResolver = new TestSerializerContext() });
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions());
|
||||
|
||||
public async Task<CallResult<T>> Request<T>(CancellationToken ct = default) where T : class
|
||||
{
|
||||
return await SendAsync<T>("http://www.test.com", new RequestDefinition("/", HttpMethod.Get) { Weight = 0 }, null, ct);
|
||||
}
|
||||
|
||||
public async Task<CallResult<T>> RequestWithParams<T>(HttpMethod method, ParameterCollection parameters, Dictionary<string, string> headers) where T : class
|
||||
{
|
||||
return await SendAsync<T>("http://www.test.com", new RequestDefinition("/", method) { Weight = 0 }, parameters, default, additionalHeaders: headers);
|
||||
}
|
||||
|
||||
public void SetParameterPosition(HttpMethod method, HttpMethodParameterPosition position)
|
||||
{
|
||||
ParameterPositions[method] = position;
|
||||
}
|
||||
|
||||
public override TimeSpan? GetTimeOffset()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
|
||||
=> new TestAuthProvider(credentials);
|
||||
|
||||
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override TimeSyncInfo GetTimeSyncInfo()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class TestRestApi2Client : RestApiClient
|
||||
{
|
||||
public TestRestApi2Client(TestClientOptions options) : base(new TraceLogger(), null, "https://localhost:123", options, options.Api2Options)
|
||||
{
|
||||
RequestFactory = new Mock<IRequestFactory>().Object;
|
||||
}
|
||||
|
||||
protected override IStreamMessageAccessor CreateAccessor() => new SystemTextJsonStreamMessageAccessor(new System.Text.Json.JsonSerializerOptions());
|
||||
protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions());
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
|
||||
|
||||
public async Task<CallResult<T>> Request<T>(CancellationToken ct = default) where T : class
|
||||
{
|
||||
return await SendAsync<T>("http://www.test.com", new RequestDefinition("/", HttpMethod.Get) { Weight = 0 }, null, ct);
|
||||
}
|
||||
|
||||
protected override Error ParseErrorResponse(int httpStatusCode, KeyValuePair<string, string[]>[] responseHeaders, IMessageAccessor accessor, Exception exception)
|
||||
{
|
||||
var errorData = accessor.Deserialize<TestError>();
|
||||
|
||||
return new ServerError(errorData.Data.ErrorCode, errorData.Data.ErrorMessage);
|
||||
}
|
||||
|
||||
public override TimeSpan? GetTimeOffset()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
|
||||
=> new TestAuthProvider(credentials);
|
||||
|
||||
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override TimeSyncInfo GetTimeSyncInfo()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class TestError
|
||||
{
|
||||
[JsonPropertyName("errorCode")]
|
||||
public int ErrorCode { get; set; }
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public class ParseErrorTestRestClient: TestRestClient
|
||||
{
|
||||
public ParseErrorTestRestClient() { }
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//using System;
|
||||
//using System.IO;
|
||||
//using System.Net.WebSockets;
|
||||
//using System.Security.Authentication;
|
||||
//using System.Text;
|
||||
//using System.Threading.Tasks;
|
||||
//using CryptoExchange.Net.Interfaces;
|
||||
//using CryptoExchange.Net.Objects;
|
||||
|
||||
//namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
//{
|
||||
// public class TestSocket: IWebsocket
|
||||
// {
|
||||
// public bool CanConnect { get; set; }
|
||||
// public bool Connected { get; set; }
|
||||
|
||||
// public event Func<Task> OnClose;
|
||||
//#pragma warning disable 0067
|
||||
// public event Func<Task> OnReconnected;
|
||||
// public event Func<Task> OnReconnecting;
|
||||
// public event Func<int, Task> OnRequestRateLimited;
|
||||
//#pragma warning restore 0067
|
||||
// public event Func<int, Task> OnRequestSent;
|
||||
// public event Func<WebSocketMessageType, ReadOnlyMemory<byte>, Task> OnStreamMessage;
|
||||
// public event Func<Exception, Task> OnError;
|
||||
// public event Func<Task> OnOpen;
|
||||
// public Func<Task<Uri>> GetReconnectionUrl { get; set; }
|
||||
|
||||
// public int Id { get; }
|
||||
// public bool ShouldReconnect { get; set; }
|
||||
// public TimeSpan Timeout { get; set; }
|
||||
// public Func<string, string> DataInterpreterString { get; set; }
|
||||
// public Func<byte[], string> DataInterpreterBytes { get; set; }
|
||||
// public DateTime? DisconnectTime { get; set; }
|
||||
// public string Url { get; }
|
||||
// public bool IsClosed => !Connected;
|
||||
// public bool IsOpen => Connected;
|
||||
// public bool PingConnection { get; set; }
|
||||
// public TimeSpan PingInterval { get; set; }
|
||||
// public SslProtocols SSLProtocols { get; set; }
|
||||
// public Encoding Encoding { get; set; }
|
||||
|
||||
// public int ConnectCalls { get; private set; }
|
||||
// public bool Reconnecting { get; set; }
|
||||
// public string Origin { get; set; }
|
||||
// public int? RatelimitPerSecond { get; set; }
|
||||
|
||||
// public double IncomingKbps => throw new NotImplementedException();
|
||||
|
||||
// public Uri Uri => new Uri("");
|
||||
|
||||
// public TimeSpan KeepAliveInterval { get; set; }
|
||||
|
||||
// public static int lastId = 0;
|
||||
// public static object lastIdLock = new object();
|
||||
|
||||
// public TestSocket()
|
||||
// {
|
||||
// lock (lastIdLock)
|
||||
// {
|
||||
// Id = lastId + 1;
|
||||
// lastId++;
|
||||
// }
|
||||
// }
|
||||
|
||||
// public Task<CallResult> ConnectAsync()
|
||||
// {
|
||||
// Connected = CanConnect;
|
||||
// ConnectCalls++;
|
||||
// if (CanConnect)
|
||||
// InvokeOpen();
|
||||
// return Task.FromResult(CanConnect ? new CallResult(null) : new CallResult(new CantConnectError()));
|
||||
// }
|
||||
|
||||
// public bool Send(int requestId, string data, int weight)
|
||||
// {
|
||||
// if(!Connected)
|
||||
// throw new Exception("Socket not connected");
|
||||
// OnRequestSent?.Invoke(requestId);
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// public void Reset()
|
||||
// {
|
||||
// }
|
||||
|
||||
// public Task CloseAsync()
|
||||
// {
|
||||
// Connected = false;
|
||||
// DisconnectTime = DateTime.UtcNow;
|
||||
// OnClose?.Invoke();
|
||||
// return Task.FromResult(0);
|
||||
// }
|
||||
|
||||
// public void SetProxy(string host, int port)
|
||||
// {
|
||||
// throw new NotImplementedException();
|
||||
// }
|
||||
// public void Dispose()
|
||||
// {
|
||||
// }
|
||||
|
||||
// public void InvokeClose()
|
||||
// {
|
||||
// Connected = false;
|
||||
// DisconnectTime = DateTime.UtcNow;
|
||||
// Reconnecting = true;
|
||||
// OnClose?.Invoke();
|
||||
// }
|
||||
|
||||
// public void InvokeOpen()
|
||||
// {
|
||||
// OnOpen?.Invoke();
|
||||
// }
|
||||
|
||||
// public void InvokeMessage(string data)
|
||||
// {
|
||||
// OnStreamMessage?.Invoke(WebSocketMessageType.Text, new ReadOnlyMemory<byte>(Encoding.UTF8.GetBytes(data))).Wait();
|
||||
// }
|
||||
|
||||
// public void SetProxy(ApiProxy proxy)
|
||||
// {
|
||||
// throw new NotImplementedException();
|
||||
// }
|
||||
|
||||
// public void InvokeError(Exception error)
|
||||
// {
|
||||
// OnError?.Invoke(error);
|
||||
// }
|
||||
// public Task ReconnectAsync() => Task.CompletedTask;
|
||||
// }
|
||||
//}
|
||||
@@ -1,140 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Clients;
|
||||
using CryptoExchange.Net.Converters.MessageParsing;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using CryptoExchange.Net.Testing.Implementations;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.Options;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests.TestImplementations
|
||||
{
|
||||
internal class TestSocketClient: BaseSocketClient
|
||||
{
|
||||
public TestSubSocketClient SubClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new instance of KucoinSocketClient
|
||||
/// </summary>
|
||||
/// <param name="optionsFunc">Configure the options to use for this client</param>
|
||||
public TestSocketClient(Action<TestSocketOptions> optionsDelegate = null)
|
||||
: this(Options.Create(ApplyOptionsDelegate(optionsDelegate)), null)
|
||||
{
|
||||
}
|
||||
|
||||
public TestSocketClient(IOptions<TestSocketOptions> options, ILoggerFactory loggerFactory = null) : base(loggerFactory, "Test")
|
||||
{
|
||||
Initialize(options.Value);
|
||||
|
||||
SubClient = AddApiClient(new TestSubSocketClient(options.Value, options.Value.SubOptions));
|
||||
SubClient.SocketFactory = new Mock<IWebsocketFactory>().Object;
|
||||
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<ILogger>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket("https://test.com"));
|
||||
}
|
||||
|
||||
public TestSocket CreateSocket()
|
||||
{
|
||||
Mock.Get(SubClient.SocketFactory).Setup(f => f.CreateWebsocket(It.IsAny<ILogger>(), It.IsAny<WebSocketParameters>())).Returns(new TestSocket("https://test.com"));
|
||||
return (TestSocket)SubClient.CreateSocketInternal("https://localhost:123/");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class TestEnvironment : TradeEnvironment
|
||||
{
|
||||
public string TestAddress { get; }
|
||||
|
||||
public TestEnvironment(string name, string url) : base(name)
|
||||
{
|
||||
TestAddress = url;
|
||||
}
|
||||
}
|
||||
|
||||
public class TestSocketOptions: SocketExchangeOptions<TestEnvironment>
|
||||
{
|
||||
public static TestSocketOptions Default = new TestSocketOptions
|
||||
{
|
||||
Environment = new TestEnvironment("Live", "https://test.test")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public TestSocketOptions()
|
||||
{
|
||||
Default?.Set(this);
|
||||
}
|
||||
|
||||
public SocketApiOptions SubOptions { get; set; } = new SocketApiOptions();
|
||||
|
||||
internal TestSocketOptions Set(TestSocketOptions targetOptions)
|
||||
{
|
||||
targetOptions = base.Set<TestSocketOptions>(targetOptions);
|
||||
targetOptions.SubOptions = SubOptions.Set(targetOptions.SubOptions);
|
||||
return targetOptions;
|
||||
}
|
||||
}
|
||||
|
||||
public class TestSubSocketClient : SocketApiClient
|
||||
{
|
||||
private MessagePath _channelPath = MessagePath.Get().Property("channel");
|
||||
private MessagePath _actionPath = MessagePath.Get().Property("action");
|
||||
private MessagePath _topicPath = MessagePath.Get().Property("topic");
|
||||
|
||||
public Subscription TestSubscription { get; private set; } = null;
|
||||
|
||||
public TestSubSocketClient(TestSocketOptions options, SocketApiOptions apiOptions) : base(new TraceLogger(), options.Environment.TestAddress, options, apiOptions)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
protected internal override IByteMessageAccessor CreateAccessor(WebSocketMessageType type) => new SystemTextJsonByteMessageAccessor(new System.Text.Json.JsonSerializerOptions());
|
||||
protected internal override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions());
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}";
|
||||
|
||||
internal IWebsocket CreateSocketInternal(string address)
|
||||
{
|
||||
return CreateSocket(address);
|
||||
}
|
||||
|
||||
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials)
|
||||
=> new TestAuthProvider(credentials);
|
||||
|
||||
public CallResult ConnectSocketSub(SocketConnection sub)
|
||||
{
|
||||
return ConnectSocketAsync(sub, default).Result;
|
||||
}
|
||||
|
||||
public override string GetListenerIdentifier(IMessageAccessor message)
|
||||
{
|
||||
if (!message.IsValid)
|
||||
{
|
||||
return "topic";
|
||||
}
|
||||
|
||||
var id = message.GetValue<string>(_channelPath);
|
||||
id ??= message.GetValue<string>(_topicPath);
|
||||
|
||||
return message.GetValue<string>(_actionPath) + "-" + id;
|
||||
}
|
||||
|
||||
public Task<CallResult<UpdateSubscription>> SubscribeToSomethingAsync(string channel, Action<DataEvent<string>> onUpdate, CancellationToken ct)
|
||||
{
|
||||
TestSubscription = new TestSubscriptionWithResponseCheck<string>(channel, onUpdate);
|
||||
return SubscribeAsync(TestSubscription, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using CryptoExchange.Net.UnitTests.TestImplementations;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object>))]
|
||||
[JsonSerializable(typeof(TestObject))]
|
||||
internal partial class TestSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using CryptoExchange.Net;
|
||||
using CryptoExchange.Net.Objects;
|
||||
|
||||
namespace CryptoExchange.Net.UnitTests
|
||||
{
|
||||
internal class UriSerializationTests
|
||||
{
|
||||
[Test]
|
||||
public void CreateParamString_SerializesBasicValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", "1" },
|
||||
{ "b", 2 },
|
||||
{ "c", true }
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamString_SerializesArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1", "2" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a[]=1&a[]=2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamStringEncoded_SerializesArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1+2", "2+3" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.Array);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a[]=1%2B2&a[]=2%2B3"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamString_SerializesJsonArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1", "2" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.JsonArray);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=[1,2]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamStringEncoded_SerializesJsonArrayValuesCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1+2", "2+3" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.JsonArray);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=[1%2B2,2%2B3]"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamString_SerializesMultipleValuesArrayCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1", "2" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.MultipleValues);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1&a=2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateParamStringEncoded_SerializesMultipleValuesArrayCorrectly()
|
||||
{
|
||||
var parameters = new Dictionary<string, object>()
|
||||
{
|
||||
{ "a", new [] { "1+2", "2+3" } },
|
||||
};
|
||||
|
||||
var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.MultipleValues);
|
||||
|
||||
Assert.That(parameterString, Is.EqualTo("a=1%2B2&a=2%2B3"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,19 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Converters.MessageParsing;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Api credentials, used to sign requests accessing private endpoints
|
||||
/// </summary>
|
||||
public class ApiCredentials
|
||||
public abstract class ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// The api key / label to authenticate requests
|
||||
/// Validate the API credentials
|
||||
/// </summary>
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The api secret or private key to authenticate requests
|
||||
/// </summary>
|
||||
public string Secret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The api passphrase. Not needed on all exchanges
|
||||
/// </summary>
|
||||
public string? Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of the credentials
|
||||
/// </summary>
|
||||
public ApiCredentialsType CredentialType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create Api credentials providing an api key and secret for authentication
|
||||
/// </summary>
|
||||
/// <param name="key">The api key / label used for identification</param>
|
||||
/// <param name="secret">The api secret or private key used for signing</param>
|
||||
/// <param name="pass">The api pass for the key. Not always needed</param>
|
||||
/// <param name="credentialType">The type of credentials</param>
|
||||
public ApiCredentials(string key, string secret, string? pass = null, ApiCredentialsType credentialType = ApiCredentialsType.Hmac)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(secret))
|
||||
throw new ArgumentException("Key and secret can't be null/empty");
|
||||
|
||||
CredentialType = credentialType;
|
||||
Key = key;
|
||||
Secret = secret;
|
||||
Pass = pass;
|
||||
}
|
||||
public abstract void Validate();
|
||||
|
||||
/// <summary>
|
||||
/// Copy the credentials
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public virtual ApiCredentials Copy()
|
||||
{
|
||||
return new ApiCredentials(Key, Secret, Pass, CredentialType);
|
||||
}
|
||||
public abstract ApiCredentials Copy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Credentials type
|
||||
/// </summary>
|
||||
public enum ApiCredentialsType
|
||||
{
|
||||
/// <summary>
|
||||
/// Hmac keys credentials
|
||||
/// </summary>
|
||||
Hmac,
|
||||
/// <summary>
|
||||
/// Rsa keys credentials in xml format
|
||||
/// </summary>
|
||||
RsaXml,
|
||||
/// <summary>
|
||||
/// Rsa keys credentials in pem/base64 format. Only available for .NetStandard 2.1 and up, use xml format for lower.
|
||||
/// </summary>
|
||||
RsaPem
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,18 @@
|
||||
using CryptoExchange.Net.Converters.SystemTextJson;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
#if NET8_0_OR_GREATER
|
||||
using NSec.Cryptography;
|
||||
#endif
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using System.Net;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
@@ -19,62 +25,24 @@ namespace CryptoExchange.Net.Authentication
|
||||
internal IAuthTimeProvider TimeProvider { get; set; } = new AuthTimeProvider();
|
||||
|
||||
/// <summary>
|
||||
/// Provided credentials
|
||||
/// The public identifier for the provided credentials
|
||||
/// </summary>
|
||||
protected internal readonly ApiCredentials _credentials;
|
||||
public abstract string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Byte representation of the secret
|
||||
/// Authenticate a REST request
|
||||
/// </summary>
|
||||
protected byte[] _sBytes;
|
||||
/// <param name="apiClient">The API client sending the request</param>
|
||||
/// <param name="requestConfig">The request configuration</param>
|
||||
public abstract void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig);
|
||||
|
||||
/// <summary>
|
||||
/// Get the API key of the current credentials
|
||||
/// Get an authentication query for a websocket
|
||||
/// </summary>
|
||||
public string ApiKey => _credentials.Key!;
|
||||
/// <summary>
|
||||
/// Get the Passphrase of the current credentials
|
||||
/// </summary>
|
||||
public string? Pass => _credentials.Pass;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="credentials"></param>
|
||||
protected AuthenticationProvider(ApiCredentials credentials)
|
||||
{
|
||||
if (credentials.Key == null || credentials.Secret == null)
|
||||
throw new ArgumentException("ApiKey/Secret needed");
|
||||
|
||||
_credentials = credentials;
|
||||
_sBytes = Encoding.UTF8.GetBytes(credentials.Secret);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticate a request. Output parameters should include the providedParameters input
|
||||
/// </summary>
|
||||
/// <param name="apiClient">The Api client sending the request</param>
|
||||
/// <param name="uri">The uri for the request</param>
|
||||
/// <param name="method">The method of the request</param>
|
||||
/// <param name="auth">If the requests should be authenticated</param>
|
||||
/// <param name="arraySerialization">Array serialization type</param>
|
||||
/// <param name="requestBodyFormat">The formatting of the request body</param>
|
||||
/// <param name="uriParameters">Parameters that need to be in the Uri of the request. Should include the provided parameters if they should go in the uri</param>
|
||||
/// <param name="bodyParameters">Parameters that need to be in the body of the request. Should include the provided parameters if they should go in the body</param>
|
||||
/// <param name="headers">The headers that should be send with the request</param>
|
||||
/// <param name="parameterPosition">The position where the providedParameters should go</param>
|
||||
public abstract void AuthenticateRequest(
|
||||
RestApiClient apiClient,
|
||||
Uri uri,
|
||||
HttpMethod method,
|
||||
ref IDictionary<string, object>? uriParameters,
|
||||
ref IDictionary<string, object>? bodyParameters,
|
||||
ref Dictionary<string, string>? headers,
|
||||
bool auth,
|
||||
ArrayParametersSerialization arraySerialization,
|
||||
HttpMethodParameterPosition parameterPosition,
|
||||
RequestBodyFormat requestBodyFormat
|
||||
);
|
||||
/// <param name="apiClient">The API client sending the request</param>
|
||||
/// <param name="connection">The connection to authenticate</param>
|
||||
/// <param name="context">Optional context required for creating the authentication query</param>
|
||||
public virtual Query? GetAuthenticationQuery(SocketApiClient apiClient, SocketConnection connection, Dictionary<string, object?>? context = null) => null;
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 sign the data and return the bytes
|
||||
@@ -257,6 +225,278 @@ namespace CryptoExchange.Net.Authentication
|
||||
return encryptor.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA256(HMACCredential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA256(credential,Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA256(HMACCredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA256(credential.GetSBytes());
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA384(HMACCredential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA384(credential, Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA384 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA384(HMACCredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA384(credential.GetSBytes());
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA512(HMACCredential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignHMACSHA512(credential, Encoding.UTF8.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA512 sign the data and return the hash
|
||||
/// </summary>
|
||||
protected string SignHMACSHA512(HMACCredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA512(credential.GetSBytes());
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 sign the data
|
||||
/// </summary>
|
||||
protected string SignRSASHA256(RSACredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var rsa = credential.GetSigner();
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA384 sign the data
|
||||
/// </summary>
|
||||
protected string SignRSASHA384(RSACredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var rsa = credential.GetSigner();
|
||||
using var sha384 = SHA384.Create();
|
||||
var hash = sha384.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA512 sign the data
|
||||
/// </summary>
|
||||
protected string SignRSASHA512(RSACredential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var rsa = credential.GetSigner();
|
||||
using var sha512 = SHA512.Create();
|
||||
var hash = sha512.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
public string SignEd25519(Ed25519Credential credential, string data, SignOutputType? outputType = null)
|
||||
=> SignEd25519(credential, Encoding.ASCII.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
public string SignEd25519(Ed25519Credential credential, byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var signKey = credential.GetSigningKey();
|
||||
var resultBytes = SignatureAlgorithm.Ed25519.Sign(signKey, data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to hex string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToHexString(byte[] buff)
|
||||
=> BytesToHexString(new ArraySegment<byte>(buff));
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to hex string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToHexString(ArraySegment<byte> buff)
|
||||
{
|
||||
#if NET9_0_OR_GREATER
|
||||
return Convert.ToHexString(buff);
|
||||
#else
|
||||
var result = string.Empty;
|
||||
foreach (var t in buff)
|
||||
result += t.ToString("X2");
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a hex encoded string to byte array
|
||||
/// </summary>
|
||||
/// <param name="hexString"></param>
|
||||
/// <returns></returns>
|
||||
protected static byte[] HexToBytesString(string hexString)
|
||||
{
|
||||
if (hexString.StartsWith("0x"))
|
||||
hexString = hexString.Substring(2);
|
||||
|
||||
byte[] bytes = new byte[hexString.Length / 2];
|
||||
for (int i = 0; i < hexString.Length; i += 2)
|
||||
{
|
||||
string hexSubstring = hexString.Substring(i, 2);
|
||||
bytes[i / 2] = Convert.ToByte(hexSubstring, 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to base64 string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToBase64String(byte[] buff)
|
||||
{
|
||||
return Convert.ToBase64String(buff);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current timestamp including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected DateTime GetTimestamp(RestApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
{
|
||||
var result = TimeProvider.GetTime().Add(TimeOffsetManager.GetRestOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
|
||||
if (includeOneSecondOffset)
|
||||
result = result.AddSeconds(-1);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current timestamp including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected DateTime GetTimestamp(SocketApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
{
|
||||
var timestamp = TimeProvider.GetTime();
|
||||
if(apiClient.ApiOptions.AutoTimestamp ?? apiClient.ClientOptions.AutoTimestamp)
|
||||
timestamp = timestamp.Add(-TimeOffsetManager.GetSocketOffset(apiClient.ClientName) ?? TimeSpan.Zero)!;
|
||||
|
||||
if (includeOneSecondOffset)
|
||||
timestamp = timestamp.AddSeconds(-1);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a string including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected string GetMillisecondTimestamp(RestApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a string including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected string GetMillisecondTimestamp(SocketApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a long including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected long GetMillisecondTimestampLong(RestApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value;
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a long including the time sync offset from the api client
|
||||
/// </summary>
|
||||
protected long GetMillisecondTimestampLong(SocketApiClient apiClient, bool includeOneSecondOffset = true)
|
||||
=> DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient, includeOneSecondOffset)).Value;
|
||||
|
||||
/// <summary>
|
||||
/// Return the serialized request body
|
||||
/// </summary>
|
||||
/// <param name="serializer"></param>
|
||||
/// <param name="parameters"></param>
|
||||
/// <returns></returns>
|
||||
protected static string GetSerializedBody(IMessageSerializer serializer, IDictionary<string, object> parameters)
|
||||
{
|
||||
if (serializer is not IStringMessageSerializer stringSerializer)
|
||||
throw new InvalidOperationException("Non-string message serializer can't get serialized request body");
|
||||
|
||||
if (parameters?.Count == 1 && parameters.TryGetValue(Constants.BodyPlaceHolderKey, out object? value))
|
||||
return stringSerializer.Serialize(value);
|
||||
else
|
||||
return stringSerializer.Serialize(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class AuthenticationProvider<TApiCredentials> : AuthenticationProvider
|
||||
where TApiCredentials: ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// API credentials used for signing requests
|
||||
/// </summary>
|
||||
public TApiCredentials ApiCredentials { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected AuthenticationProvider(TApiCredentials credentials)
|
||||
{
|
||||
credentials.Validate();
|
||||
|
||||
ApiCredentials = credentials;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class AuthenticationProvider<TApiCredentials, TCredentialType> : AuthenticationProvider<TApiCredentials>
|
||||
where TApiCredentials : ApiCredentials
|
||||
where TCredentialType : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// The specific credential type used for signing requests.
|
||||
/// </summary>
|
||||
public TCredentialType Credential { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Key => Credential.Key;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected AuthenticationProvider(
|
||||
TApiCredentials credentials,
|
||||
TCredentialType? credential) : base(credentials)
|
||||
{
|
||||
if (credential == null)
|
||||
throw new ArgumentException($"Missing \"{typeof(TCredentialType).Name}\" credentials on \"{credentials.GetType().Name}\"");
|
||||
|
||||
Credential = credential;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMACSHA256 sign the data and return the hash
|
||||
/// </summary>
|
||||
@@ -274,9 +514,10 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA256(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA256(_sBytes);
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not HMACCredential hmacCredential)
|
||||
throw new InvalidOperationException($"Invalid HMAC signing without HMAC credentials provided");
|
||||
|
||||
return SignHMACSHA256(hmacCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -296,9 +537,10 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA384(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA384(_sBytes);
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not HMACCredential hmacCredential)
|
||||
throw new InvalidOperationException($"Invalid HMAC signing without HMAC credentials provided");
|
||||
|
||||
return SignHMACSHA384(hmacCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -318,9 +560,10 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <returns></returns>
|
||||
protected string SignHMACSHA512(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var encryptor = new HMACSHA512(_sBytes);
|
||||
var resultBytes = encryptor.ComputeHash(data);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not HMACCredential hmacCredential)
|
||||
throw new InvalidOperationException($"Invalid HMAC signing without HMAC credentials provided");
|
||||
|
||||
return SignHMACSHA512(hmacCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -331,11 +574,10 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <returns></returns>
|
||||
protected string SignRSASHA256(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var rsa = CreateRSA();
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not RSACredential rsaCredential)
|
||||
throw new InvalidOperationException($"Invalid RSA signing without RSA credentials provided");
|
||||
|
||||
return SignRSASHA256(rsaCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -346,11 +588,10 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <returns></returns>
|
||||
protected string SignRSASHA384(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var rsa = CreateRSA();
|
||||
using var sha384 = SHA384.Create();
|
||||
var hash = sha384.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not RSACredential rsaCredential)
|
||||
throw new InvalidOperationException($"Invalid RSA signing without RSA credentials provided");
|
||||
|
||||
return SignRSASHA384(rsaCredential, data, outputType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -361,132 +602,29 @@ namespace CryptoExchange.Net.Authentication
|
||||
/// <returns></returns>
|
||||
protected string SignRSASHA512(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
using var rsa = CreateRSA();
|
||||
using var sha512 = SHA512.Create();
|
||||
var hash = sha512.ComputeHash(data);
|
||||
var resultBytes = rsa.SignHash(hash, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1);
|
||||
return outputType == SignOutputType.Base64 ? BytesToBase64String(resultBytes) : BytesToHexString(resultBytes);
|
||||
if (Credential is not RSACredential rsaCredential)
|
||||
throw new InvalidOperationException($"Invalid RSA signing without RSA credentials provided");
|
||||
|
||||
return SignRSASHA512(rsaCredential, data, outputType);
|
||||
}
|
||||
|
||||
private RSA CreateRSA()
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
public string SignEd25519(string data, SignOutputType? outputType = null)
|
||||
=> SignEd25519(Encoding.ASCII.GetBytes(data), outputType);
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 sign the data
|
||||
/// </summary>
|
||||
public string SignEd25519(byte[] data, SignOutputType? outputType = null)
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
if (_credentials.CredentialType == ApiCredentialsType.RsaPem)
|
||||
{
|
||||
#if NETSTANDARD2_1_OR_GREATER || NET9_0_OR_GREATER
|
||||
// Read from pem private key
|
||||
var key = _credentials.Secret!
|
||||
.Replace("\n", "")
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Trim();
|
||||
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(
|
||||
key)
|
||||
, out _);
|
||||
#else
|
||||
throw new Exception("Pem format not supported when running from .NetStandard2.0. Convert the private key to xml format.");
|
||||
if (Credential is not Ed25519Credential ed25519Credential)
|
||||
throw new InvalidOperationException($"Invalid Ed25519 signing without Ed25519 credentials provided");
|
||||
|
||||
return SignEd25519(ed25519Credential, data, outputType);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
else if (_credentials.CredentialType == ApiCredentialsType.RsaXml)
|
||||
{
|
||||
// Read from xml private key format
|
||||
rsa.FromXmlString(_credentials.Secret!);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Invalid credentials type");
|
||||
}
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to hex string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToHexString(byte[] buff)
|
||||
{
|
||||
#if NET9_0_OR_GREATER
|
||||
return Convert.ToHexString(buff);
|
||||
#else
|
||||
var result = string.Empty;
|
||||
foreach (var t in buff)
|
||||
result += t.ToString("X2");
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert byte array to base64 string
|
||||
/// </summary>
|
||||
/// <param name="buff"></param>
|
||||
/// <returns></returns>
|
||||
protected static string BytesToBase64String(byte[] buff)
|
||||
{
|
||||
return Convert.ToBase64String(buff);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current timestamp including the time sync offset from the api client
|
||||
/// </summary>
|
||||
/// <param name="apiClient"></param>
|
||||
/// <returns></returns>
|
||||
protected DateTime GetTimestamp(RestApiClient apiClient)
|
||||
{
|
||||
return TimeProvider.GetTime().Add(apiClient.GetTimeOffset() ?? TimeSpan.Zero)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a string including the time sync offset from the api client
|
||||
/// </summary>
|
||||
/// <param name="apiClient"></param>
|
||||
/// <returns></returns>
|
||||
protected string GetMillisecondTimestamp(RestApiClient apiClient)
|
||||
{
|
||||
return DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient)).Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get millisecond timestamp as a long including the time sync offset from the api client
|
||||
/// </summary>
|
||||
/// <param name="apiClient"></param>
|
||||
/// <returns></returns>
|
||||
protected long GetMillisecondTimestampLong(RestApiClient apiClient)
|
||||
{
|
||||
return DateTimeConverter.ConvertToMilliseconds(GetTimestamp(apiClient)).Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the serialized request body
|
||||
/// </summary>
|
||||
/// <param name="serializer"></param>
|
||||
/// <param name="parameters"></param>
|
||||
/// <returns></returns>
|
||||
protected static string GetSerializedBody(IMessageSerializer serializer, IDictionary<string, object> parameters)
|
||||
{
|
||||
if (serializer is not IStringMessageSerializer stringSerializer)
|
||||
throw new InvalidOperationException("Non-string message serializer can't get serialized request body");
|
||||
|
||||
if (parameters.Count == 1 && parameters.TryGetValue(Constants.BodyPlaceHolderKey, out object? value))
|
||||
return stringSerializer.Serialize(value);
|
||||
else
|
||||
return stringSerializer.Serialize(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class AuthenticationProvider<TApiCredentials> : AuthenticationProvider where TApiCredentials : ApiCredentials
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected new TApiCredentials _credentials => (TApiCredentials)base._credentials;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="credentials"></param>
|
||||
protected AuthenticationProvider(TApiCredentials credentials) : base(credentials)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
namespace CryptoExchange.Net.Authentication
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for a set of credentials
|
||||
/// </summary>
|
||||
public abstract class CredentialSet : ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// The (public) key/identifier for this credential pair
|
||||
/// </summary>
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public CredentialSet() { }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public CredentialSet(string key)
|
||||
{
|
||||
Key = key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate the API credential
|
||||
/// </summary>
|
||||
public override void Validate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Key))
|
||||
throw new ArgumentException($"Key not set on {GetType().Name}", nameof(Key));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Api key credentials
|
||||
/// </summary>
|
||||
public class ApiKeyCredential : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key</param>
|
||||
public ApiKeyCredential(string key) : base(key)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new ApiKeyCredential(Key);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMAC credentials
|
||||
/// </summary>
|
||||
public class HMACCredential : CredentialSet
|
||||
{
|
||||
private byte[]? _sBytes;
|
||||
|
||||
/// <summary>
|
||||
/// API secret
|
||||
/// </summary>
|
||||
public string Secret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public HMACCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="secret">Api secret</param>
|
||||
public HMACCredential(string key, string secret) : base(key)
|
||||
{
|
||||
Secret = secret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the secret value bytes
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public byte[] GetSBytes()
|
||||
{
|
||||
return _sBytes ??= Encoding.UTF8.GetBytes(Secret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new HMACCredential(Key, Secret);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Secret))
|
||||
throw new ArgumentException($"Secret not set on {GetType().Name}", nameof(Secret));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMAC credentials
|
||||
/// </summary>
|
||||
public class HMACPassCredential : HMACCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public HMACPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="secret">Api secret</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public HMACPassCredential(string key, string secret, string pass) : base(key, secret)
|
||||
{
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new HMACPassCredential(Key, Secret, Pass);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"Pass not set on {GetType().Name}", nameof(Pass));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA credentials
|
||||
/// </summary>
|
||||
public abstract class RSACredential : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Private key
|
||||
/// </summary>
|
||||
public string PrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSACredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public RSACredential(string key, string privateKey) : base(key)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public abstract RSA GetSigner();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(PrivateKey))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA credentials
|
||||
/// </summary>
|
||||
public abstract class RSAPassCredential : RSACredential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public RSAPassCredential(string key, string privateKey, string pass) : base(key, privateKey)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
#if NETSTANDARD2_1_OR_GREATER || NET7_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// RSA credentials in PEM/base64 format
|
||||
/// </summary>
|
||||
public class RSAPemCredential : RSACredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAPemCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public RSAPemCredential(string key, string privateKey) : base(key, privateKey)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
var key = PrivateKey!
|
||||
.Replace("\n", "")
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Trim();
|
||||
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(
|
||||
key)
|
||||
, out _);
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAPemCredential(Key, PrivateKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA PEM/Base64 credentials
|
||||
/// </summary>
|
||||
public class RSAPemPassCredential : RSAPassCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAPemPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Api secret</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public RSAPemPassCredential(string key, string privateKey, string pass) : base(key, privateKey, pass)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
var key = PrivateKey!
|
||||
.Replace("\n", "")
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Trim();
|
||||
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(
|
||||
key)
|
||||
, out _);
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAPemPassCredential(Key, PrivateKey, Pass);
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// RSA credentials in XML format
|
||||
/// </summary>
|
||||
public class RSAXmlCredential : RSACredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAXmlCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public RSAXmlCredential(string key, string privateKey) : base(key, privateKey)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
rsa.FromXmlString(PrivateKey);
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAXmlCredential(Key, PrivateKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RSA XML credentials
|
||||
/// </summary>
|
||||
public class RSAXmlPassCredential : RSAPassCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public RSAXmlPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Api secret</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public RSAXmlPassCredential(string key, string privateKey, string pass) : base(key, privateKey, pass)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get RSA signer
|
||||
/// </summary>
|
||||
public override RSA GetSigner()
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
rsa.FromXmlString(PrivateKey);
|
||||
return rsa;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new RSAXmlPassCredential(Key, PrivateKey, Pass);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Credentials in Ed25519 format
|
||||
/// </summary>
|
||||
public class Ed25519Credential : CredentialSet
|
||||
{
|
||||
private NSec.Cryptography.Key? _signKey;
|
||||
|
||||
/// <summary>
|
||||
/// Private key
|
||||
/// </summary>
|
||||
public string PrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public Ed25519Credential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public Ed25519Credential(string key, string privateKey) : base(key)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get signing key
|
||||
/// </summary>
|
||||
public NSec.Cryptography.Key GetSigningKey()
|
||||
{
|
||||
if (_signKey != null)
|
||||
return _signKey;
|
||||
|
||||
var key = PrivateKey!
|
||||
.Replace("\n", "")
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Trim();
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
_signKey = NSec.Cryptography.Key.Import(NSec.Cryptography.SignatureAlgorithm.Ed25519, keyBytes, NSec.Cryptography.KeyBlobFormat.PkixPrivateKey);
|
||||
return _signKey;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new Ed25519Credential(Key, PrivateKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(PrivateKey))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 credentials
|
||||
/// </summary>
|
||||
public class Ed25519PassCredential : Ed25519Credential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public Ed25519PassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public Ed25519PassCredential(string key, string privateKey, string pass) : base(key, privateKey)
|
||||
{
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new Ed25519PassCredential(Key, PrivateKey, Pass);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"Pass not set on {GetType().Name}", nameof(Pass));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Credentials in ECDsa format
|
||||
/// </summary>
|
||||
public class ECDsaCredential : CredentialSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Private key
|
||||
/// </summary>
|
||||
public string PrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public ECDsaCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Public key</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
public ECDsaCredential(string key, string privateKey) : base(key)
|
||||
{
|
||||
PrivateKey = privateKey;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new ECDsaCredential(Key, PrivateKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(PrivateKey))
|
||||
throw new ArgumentException($"PrivateKey not set on {GetType().Name}", nameof(PrivateKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ECDsa credentials
|
||||
/// </summary>
|
||||
public class ECDsaPassCredential : ECDsaCredential
|
||||
{
|
||||
/// <summary>
|
||||
/// Passphrase
|
||||
/// </summary>
|
||||
public string Pass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public ECDsaPassCredential()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="key">Api key/label</param>
|
||||
/// <param name="privateKey">Private key</param>
|
||||
/// <param name="pass">Passphrase</param>
|
||||
public ECDsaPassCredential(string key, string privateKey, string pass) : base(key, privateKey)
|
||||
{
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ApiCredentials Copy() => new ECDsaPassCredential(Key, PrivateKey, Pass);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Validate()
|
||||
{
|
||||
base.Validate();
|
||||
if (string.IsNullOrEmpty(Pass))
|
||||
throw new ArgumentException($"Pass not set on {GetType().Name}", nameof(Pass));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication.Signing
|
||||
{
|
||||
/// <summary>
|
||||
/// ABI encoding
|
||||
/// </summary>
|
||||
public static class CeAbiEncoder
|
||||
{
|
||||
/// <summary>
|
||||
/// ABI encode string as Sha3Keccack hashed byte value
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeString(string value)
|
||||
{
|
||||
var abiValueEncoded = CeSha3Keccack.CalculateHash(Encoding.UTF8.GetBytes(value));
|
||||
return abiValueEncoded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode bool value as uint256 with 1 for true and 0 for false, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeBool(bool value)
|
||||
=> AbiValueEncodeInt((byte)(value ? 1 : 0));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode byte value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(byte value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode short value as int256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(short value)
|
||||
=> AbiValueEncodeBigInteger(true, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode int value as int256, as per ABI specification
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeInt(int value)
|
||||
=> AbiValueEncodeBigInteger(true, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode long value as int256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(long value)
|
||||
=> AbiValueEncodeBigInteger(true, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode ushort value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeInt(ushort value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode uint value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeInt(uint value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode ulong value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeInt(ulong value)
|
||||
=> AbiValueEncodeBigInteger(false, new BigInteger(value));
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode big integer value as int256 or uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeBigInteger(bool signed, BigInteger value)
|
||||
{
|
||||
var result = new byte[32];
|
||||
if (signed && value < 0)
|
||||
{
|
||||
// Pad with FF
|
||||
for (int i = 0; i < result.Length; i++)
|
||||
{
|
||||
result[i] = 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
var t = value.ToByteArray();
|
||||
if (t.Length == 33)
|
||||
{
|
||||
// Strip last byte
|
||||
var strip1 = new byte[32];
|
||||
Array.Copy(t, 0, strip1, 0, 32);
|
||||
t = strip1;
|
||||
}
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
t = t.AsEnumerable().Reverse().ToArray();
|
||||
|
||||
t.CopyTo(result, result.Length - t.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode address value as uint256, as per ABI specification
|
||||
/// </summary>
|
||||
public static byte[] AbiValueEncodeAddress(string value)
|
||||
{
|
||||
var result = new byte[32];
|
||||
var h = value.HexStringToBytes();
|
||||
h.CopyTo(result, result.Length - h.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode hex string value as bytes32, as per ABI specification. The hex string is expected to be a 0x prefixed string, and the resulting bytes will be right aligned in the 32 bytes result, with leading zeros if the hex string is shorter than 32 bytes. If the hex string is longer than 32 bytes, an exception will be thrown.
|
||||
/// </summary>
|
||||
/// <param name="length"></param>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static byte[] AbiValueEncodeHexBytes(int length, string value)
|
||||
=> AbiValueEncodeBytes(value.Length, value.HexStringToBytes());
|
||||
|
||||
/// <summary>
|
||||
/// ABI encode byte array value as bytes32, as per ABI specification. The resulting bytes will be right aligned in the 32 bytes result, with leading zeros if the byte array is shorter than 32 bytes. If the byte array is longer than 32 bytes, an exception will be thrown.
|
||||
/// </summary>
|
||||
/// <param name="length"></param>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public static byte[] AbiValueEncodeBytes(int length, byte[] value)
|
||||
{
|
||||
if (length != 32)
|
||||
throw new Exception("Only 32 bytes size supported");
|
||||
|
||||
if (value.Length == 32)
|
||||
return value;
|
||||
|
||||
var result = new byte[32];
|
||||
value.CopyTo(result, result.Length - value.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication.Signing
|
||||
{
|
||||
/// <summary>
|
||||
/// EIP712 Typed Data Encoder
|
||||
/// </summary>
|
||||
public static class CeEip712TypedDataEncoder
|
||||
{
|
||||
/// <summary>
|
||||
/// Encode EIP712 typed data according to the specification, with the provided primary type, domain fields and message fields.
|
||||
/// The resulting byte array is the 0x19 0x01 prefix followed by the hash of the domain and the hash of the message, which can be signed with ECDsa secp256k1 to produce a signature that can be verified on chain with EIP712.
|
||||
/// Note that this implementation does not support all possible EIP712 types, but it should cover most common use cases
|
||||
/// </summary>
|
||||
public static byte[] EncodeEip721(
|
||||
string primaryType,
|
||||
IEnumerable<(string Name, string Type, object Value)> domainFields,
|
||||
IEnumerable<(string Name, string Type, object Value)> messageFields)
|
||||
{
|
||||
var data = new CeTypedDataRaw()
|
||||
{
|
||||
PrimaryType = primaryType,
|
||||
DomainRawValues = domainFields.Select(x => new CeMemberValue
|
||||
{
|
||||
TypeName = x.Type,
|
||||
Value = x.Value,
|
||||
}).ToArray(),
|
||||
|
||||
Message = messageFields.Select(x => new CeMemberValue
|
||||
{
|
||||
TypeName = x.Type,
|
||||
Value = x.Value,
|
||||
}).ToArray(),
|
||||
Types = new Dictionary<string, CeMemberDescription[]>
|
||||
{
|
||||
{
|
||||
"EIP712Domain",
|
||||
domainFields.Select(x => new CeMemberDescription
|
||||
{
|
||||
Name = x.Name,
|
||||
Type = x.Type
|
||||
}).ToArray()
|
||||
},
|
||||
{
|
||||
primaryType,
|
||||
messageFields.Select(x => new CeMemberDescription
|
||||
{
|
||||
Name = x.Name,
|
||||
Type = x.Type
|
||||
}).ToArray()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return EncodeTypedDataRaw(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode EIP712 typed data according to the specification, with the provided primary type, domain fields and message fields.
|
||||
/// The resulting byte array is the 0x19 0x01 prefix followed by the hash of the domain and the hash of the message, which can be signed with ECDsa secp256k1 to produce a signature that can be verified on chain with EIP712.
|
||||
/// Note that this implementation does not support all possible EIP712 types, but it should cover most common use cases
|
||||
/// </summary>
|
||||
public static byte[] EncodeTypedDataRaw(CeTypedDataRaw typedData)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(memoryStream);
|
||||
|
||||
// Write 0x19 0x01 prefix
|
||||
writer.Write((byte)0x19);
|
||||
writer.Write((byte)0x01);
|
||||
|
||||
// Write domain
|
||||
writer.Write(HashStruct(typedData.Types, "EIP712Domain", typedData.DomainRawValues));
|
||||
|
||||
// Write message
|
||||
writer.Write(HashStruct(typedData.Types, typedData.PrimaryType, typedData.Message));
|
||||
|
||||
writer.Flush();
|
||||
var result = memoryStream.ToArray();
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
private static byte[] HashStruct(IDictionary<string, CeMemberDescription[]> types, string primaryType, IEnumerable<CeMemberValue> message)
|
||||
{
|
||||
var memoryStream = new MemoryStream();
|
||||
var writer = new BinaryWriter(memoryStream);
|
||||
|
||||
// Encode the type header
|
||||
EncodeType(writer, types, primaryType);
|
||||
|
||||
// Encode the data
|
||||
EncodeData(writer, types, message);
|
||||
|
||||
writer.Flush();
|
||||
return CeSha3Keccack.CalculateHash(memoryStream.ToArray());
|
||||
|
||||
}
|
||||
|
||||
private static void EncodeData(BinaryWriter writer, IDictionary<string, CeMemberDescription[]> types, IEnumerable<CeMemberValue> memberValues)
|
||||
{
|
||||
foreach (var memberValue in memberValues)
|
||||
{
|
||||
switch (memberValue.TypeName)
|
||||
{
|
||||
case var refType when IsReferenceType(refType):
|
||||
writer.Write(HashStruct(types, memberValue.TypeName, (IEnumerable<CeMemberValue>)memberValue.Value));
|
||||
break;
|
||||
|
||||
case "string":
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeString((string)memberValue.Value));
|
||||
break;
|
||||
|
||||
case "bool":
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeBool((bool)memberValue.Value));
|
||||
break;
|
||||
|
||||
case "address":
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeAddress((string)memberValue.Value));
|
||||
break;
|
||||
|
||||
default:
|
||||
if (memberValue.TypeName.Contains("["))
|
||||
{
|
||||
var items = (IList)memberValue.Value;
|
||||
var itemsMemberValues = new List<CeMemberValue>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
itemsMemberValues.Add(new CeMemberValue()
|
||||
{
|
||||
TypeName = memberValue.TypeName.Substring(0, memberValue.TypeName.LastIndexOf("[")),
|
||||
Value = item
|
||||
});
|
||||
}
|
||||
|
||||
var memoryStream = new MemoryStream();
|
||||
var writerItem = new BinaryWriter(memoryStream);
|
||||
|
||||
EncodeData(writerItem, types, itemsMemberValues);
|
||||
writerItem.Flush();
|
||||
writer.Write(CeSha3Keccack.CalculateHash(memoryStream.ToArray()));
|
||||
}
|
||||
else if (memberValue.TypeName.StartsWith("int") || memberValue.TypeName.StartsWith("uint"))
|
||||
{
|
||||
if (memberValue.Value is string v)
|
||||
{
|
||||
if (!BigInteger.TryParse(v, out BigInteger parsedOutput))
|
||||
throw new Exception($"Failed to encode BigInteger string {v}");
|
||||
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeBigInteger(memberValue.TypeName[0] != 'u', parsedOutput));
|
||||
}
|
||||
else if (memberValue.Value is byte b)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(b));
|
||||
}
|
||||
else if (memberValue.Value is short s)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(s));
|
||||
}
|
||||
else if (memberValue.Value is int i)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(i));
|
||||
}
|
||||
else if (memberValue.Value is long l)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(l));
|
||||
}
|
||||
else if (memberValue.Value is ushort us)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(us));
|
||||
}
|
||||
else if (memberValue.Value is uint ui)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(ui));
|
||||
}
|
||||
else if (memberValue.Value is ulong ul)
|
||||
{
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeInt(ul));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Unknown number value");
|
||||
}
|
||||
}
|
||||
else if (memberValue.TypeName.StartsWith("bytes"))
|
||||
{
|
||||
var length = memberValue.TypeName.Length == 5 ? 32 : int.Parse(memberValue.TypeName.Substring(5));
|
||||
writer.Write(CeAbiEncoder.AbiValueEncodeBytes(length, (byte[])memberValue.Value));
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EncodeType(BinaryWriter writer, IDictionary<string, CeMemberDescription[]> types, string typeName)
|
||||
{
|
||||
var encodedTypes = EncodeTypes(types, typeName);
|
||||
var encodedPrimaryType = encodedTypes.Single(x => x.Key == typeName);
|
||||
var encodedReferenceTypes = encodedTypes.Where(x => x.Key != typeName).OrderBy(x => x.Key).Select(x => x.Value);
|
||||
var fullyEncodedType = encodedPrimaryType.Value + string.Join(string.Empty, encodedReferenceTypes.ToArray());
|
||||
|
||||
writer.Write(CeSha3Keccack.CalculateHash(Encoding.UTF8.GetBytes(fullyEncodedType)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a list of type => type(parameters), for example:<br />
|
||||
/// { IP712Domain, EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) }
|
||||
/// </summary>
|
||||
private static IList<KeyValuePair<string, string>> EncodeTypes(IDictionary<string, CeMemberDescription[]> types, string currentTypeName)
|
||||
{
|
||||
var currentTypeMembers = types[currentTypeName];
|
||||
var currentTypeMembersEncoded = currentTypeMembers.Select(x => x.Type + " " + x.Name);
|
||||
var result = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new KeyValuePair<string, string>(currentTypeName, currentTypeName + "(" + string.Join(",", currentTypeMembersEncoded.ToArray()) + ")")
|
||||
};
|
||||
|
||||
result.AddRange(currentTypeMembers.Select(x => x.Type.Contains("[") ? x.Type.Substring(0, x.Type.IndexOf("[")) : x.Type)
|
||||
.Distinct()
|
||||
.Where(IsReferenceType)
|
||||
.SelectMany(x => EncodeTypes(types, x)));
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static bool IsReferenceType(string typeName)
|
||||
{
|
||||
switch (typeName)
|
||||
{
|
||||
case var bytes when new Regex("bytes\\d+").IsMatch(bytes):
|
||||
case var @uint when new Regex("uint\\d+").IsMatch(@uint):
|
||||
case var @int when new Regex("int\\d+").IsMatch(@int):
|
||||
case "bytes":
|
||||
case "string":
|
||||
case "bool":
|
||||
case "address":
|
||||
case var array when array.Contains("["):
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Member description
|
||||
/// </summary>
|
||||
public class CeMemberDescription
|
||||
{
|
||||
/// <summary>
|
||||
/// Name
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Type
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Member value
|
||||
/// </summary>
|
||||
public class CeMemberValue
|
||||
{
|
||||
/// <summary>
|
||||
/// Type name
|
||||
/// </summary>
|
||||
public string TypeName { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Value
|
||||
/// </summary>
|
||||
public object Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed data raw, used for encoding EIP712 typed data with the provided primary type, domain fields and message fields.
|
||||
/// </summary>
|
||||
public class CeTypedDataRaw
|
||||
{
|
||||
/// <summary>
|
||||
/// Type dictionary
|
||||
/// </summary>
|
||||
public IDictionary<string, CeMemberDescription[]> Types { get; set; } = new Dictionary<string, CeMemberDescription[]>();
|
||||
/// <summary>
|
||||
/// Primary type
|
||||
/// </summary>
|
||||
public string PrimaryType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Message values
|
||||
/// </summary>
|
||||
public CeMemberValue[] Message { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Domain values
|
||||
/// </summary>
|
||||
public CeMemberValue[] DomainRawValues { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.Authentication.Signing
|
||||
{
|
||||
/// <summary>
|
||||
/// Sha3 Keccack hashing, as per Ethereum specification, with 256 bit output
|
||||
/// </summary>
|
||||
public class CeSha3Keccack
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculate the Keccack256 hash of the provided data, as per Ethereum specification
|
||||
/// </summary>
|
||||
public static byte[] CalculateHash(byte[] data)
|
||||
{
|
||||
var digest = new CeKeccakDigest256();
|
||||
var output = new byte[digest.GetDigestSize()];
|
||||
digest.BlockUpdate(data, data.Length);
|
||||
digest.DoFinal(output, 0);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
internal class CeKeccakDigest256
|
||||
{
|
||||
private static readonly ulong[] _keccakRoundConstants = KeccakInitializeRoundConstants();
|
||||
private static readonly int[] _keccakRhoOffsets = KeccakInitializeRhoOffsets();
|
||||
|
||||
private readonly int _rate;
|
||||
private const int _stateLength = 1600 / 8;
|
||||
private readonly ulong[] _state = new ulong[_stateLength / 8];
|
||||
private readonly byte[] _dataQueue = new byte[1536 / 8];
|
||||
private int _bitsInQueue;
|
||||
private int _fixedOutputLength;
|
||||
private bool _squeezing;
|
||||
private int _bitsAvailableForSqueezing;
|
||||
|
||||
public CeKeccakDigest256()
|
||||
{
|
||||
_rate = 1600 - (256 << 1);
|
||||
_bitsInQueue = 0;
|
||||
_squeezing = false;
|
||||
_bitsAvailableForSqueezing = 0;
|
||||
_fixedOutputLength = 1600 - _rate >> 1;
|
||||
}
|
||||
|
||||
internal void BlockUpdate(byte[] data, int length)
|
||||
{
|
||||
int bytesInQueue = _bitsInQueue >> 3;
|
||||
int rateBytes = _rate >> 3;
|
||||
|
||||
int count = 0;
|
||||
while (count < length)
|
||||
{
|
||||
if (bytesInQueue == 0 && count <= length - rateBytes)
|
||||
{
|
||||
do
|
||||
{
|
||||
KeccakAbsorb(data, count);
|
||||
count += rateBytes;
|
||||
} while (count <= length - rateBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
int partialBlock = Math.Min(rateBytes - bytesInQueue, length - count);
|
||||
Array.Copy(data, count, _dataQueue, bytesInQueue, partialBlock);
|
||||
|
||||
bytesInQueue += partialBlock;
|
||||
count += partialBlock;
|
||||
|
||||
if (bytesInQueue == rateBytes)
|
||||
{
|
||||
KeccakAbsorb(_dataQueue, 0);
|
||||
bytesInQueue = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_bitsInQueue = bytesInQueue << 3;
|
||||
}
|
||||
|
||||
internal void DoFinal(byte[] output, int outOff)
|
||||
{
|
||||
Squeeze(output, outOff, _fixedOutputLength >> 3);
|
||||
}
|
||||
|
||||
internal int GetDigestSize() => _fixedOutputLength >> 3;
|
||||
|
||||
protected void Squeeze(byte[] output, int off, int len)
|
||||
{
|
||||
if (!_squeezing)
|
||||
PadAndSwitchToSqueezingPhase();
|
||||
|
||||
long outputLength = (long)len << 3;
|
||||
long i = 0;
|
||||
while (i < outputLength)
|
||||
{
|
||||
if (_bitsAvailableForSqueezing == 0)
|
||||
{
|
||||
KeccakPermutation();
|
||||
KeccakExtract();
|
||||
_bitsAvailableForSqueezing = _rate;
|
||||
}
|
||||
|
||||
int partialBlock = (int)Math.Min(_bitsAvailableForSqueezing, outputLength - i);
|
||||
Array.Copy(_dataQueue, _rate - _bitsAvailableForSqueezing >> 3, output, off + (int)(i >> 3),
|
||||
partialBlock >> 3);
|
||||
_bitsAvailableForSqueezing -= partialBlock;
|
||||
i += partialBlock;
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong[] KeccakInitializeRoundConstants()
|
||||
{
|
||||
ulong[] keccakRoundConstants = new ulong[24];
|
||||
byte LFSRState = 0x01;
|
||||
|
||||
for (int i = 0; i < 24; i++)
|
||||
{
|
||||
keccakRoundConstants[i] = 0;
|
||||
for (int j = 0; j < 7; j++)
|
||||
{
|
||||
int bitPosition = (1 << j) - 1;
|
||||
|
||||
// LFSR86540
|
||||
|
||||
bool loBit = (LFSRState & 0x01) != 0;
|
||||
if (loBit)
|
||||
keccakRoundConstants[i] ^= 1UL << bitPosition;
|
||||
|
||||
bool hiBit = (LFSRState & 0x80) != 0;
|
||||
LFSRState <<= 1;
|
||||
if (hiBit)
|
||||
LFSRState ^= 0x71;
|
||||
}
|
||||
}
|
||||
|
||||
return keccakRoundConstants;
|
||||
}
|
||||
private static int[] KeccakInitializeRhoOffsets()
|
||||
{
|
||||
int[] keccakRhoOffsets = new int[25];
|
||||
int x, y, t, newX, newY;
|
||||
|
||||
int rhoOffset = 0;
|
||||
keccakRhoOffsets[0] = rhoOffset;
|
||||
x = 1;
|
||||
y = 0;
|
||||
for (t = 1; t < 25; t++)
|
||||
{
|
||||
rhoOffset = rhoOffset + t & 63;
|
||||
keccakRhoOffsets[x % 5 + 5 * (y % 5)] = rhoOffset;
|
||||
newX = (0 * x + 1 * y) % 5;
|
||||
newY = (2 * x + 3 * y) % 5;
|
||||
x = newX;
|
||||
y = newY;
|
||||
}
|
||||
|
||||
return keccakRhoOffsets;
|
||||
}
|
||||
|
||||
private void KeccakAbsorb(byte[] data, int off)
|
||||
{
|
||||
int count = _rate >> 6;
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
_state[i] ^= Pack.LeToUInt64(data, off);
|
||||
off += 8;
|
||||
}
|
||||
|
||||
KeccakPermutation();
|
||||
}
|
||||
|
||||
private void KeccakPermutation()
|
||||
{
|
||||
for (int i = 0; i < 24; i++)
|
||||
{
|
||||
Theta(_state);
|
||||
Rho(_state);
|
||||
Pi(_state);
|
||||
Chi(_state);
|
||||
Iota(_state, i);
|
||||
}
|
||||
}
|
||||
private static ulong LeftRotate(ulong v, int r)
|
||||
{
|
||||
return v << r | v >> -r;
|
||||
}
|
||||
|
||||
private static void Theta(ulong[] A)
|
||||
{
|
||||
ulong C0 = A[0 + 0] ^ A[0 + 5] ^ A[0 + 10] ^ A[0 + 15] ^ A[0 + 20];
|
||||
ulong C1 = A[1 + 0] ^ A[1 + 5] ^ A[1 + 10] ^ A[1 + 15] ^ A[1 + 20];
|
||||
ulong C2 = A[2 + 0] ^ A[2 + 5] ^ A[2 + 10] ^ A[2 + 15] ^ A[2 + 20];
|
||||
ulong C3 = A[3 + 0] ^ A[3 + 5] ^ A[3 + 10] ^ A[3 + 15] ^ A[3 + 20];
|
||||
ulong C4 = A[4 + 0] ^ A[4 + 5] ^ A[4 + 10] ^ A[4 + 15] ^ A[4 + 20];
|
||||
|
||||
ulong dX = LeftRotate(C1, 1) ^ C4;
|
||||
|
||||
A[0] ^= dX;
|
||||
A[5] ^= dX;
|
||||
A[10] ^= dX;
|
||||
A[15] ^= dX;
|
||||
A[20] ^= dX;
|
||||
|
||||
dX = LeftRotate(C2, 1) ^ C0;
|
||||
|
||||
A[1] ^= dX;
|
||||
A[6] ^= dX;
|
||||
A[11] ^= dX;
|
||||
A[16] ^= dX;
|
||||
A[21] ^= dX;
|
||||
|
||||
dX = LeftRotate(C3, 1) ^ C1;
|
||||
|
||||
A[2] ^= dX;
|
||||
A[7] ^= dX;
|
||||
A[12] ^= dX;
|
||||
A[17] ^= dX;
|
||||
A[22] ^= dX;
|
||||
|
||||
dX = LeftRotate(C4, 1) ^ C2;
|
||||
|
||||
A[3] ^= dX;
|
||||
A[8] ^= dX;
|
||||
A[13] ^= dX;
|
||||
A[18] ^= dX;
|
||||
A[23] ^= dX;
|
||||
|
||||
dX = LeftRotate(C0, 1) ^ C3;
|
||||
|
||||
A[4] ^= dX;
|
||||
A[9] ^= dX;
|
||||
A[14] ^= dX;
|
||||
A[19] ^= dX;
|
||||
A[24] ^= dX;
|
||||
}
|
||||
|
||||
private static void Rho(ulong[] A)
|
||||
{
|
||||
// KeccakRhoOffsets[0] == 0
|
||||
for (int x = 1; x < 25; x++)
|
||||
{
|
||||
A[x] = LeftRotate(A[x], _keccakRhoOffsets[x]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Pi(ulong[] A)
|
||||
{
|
||||
ulong a1 = A[1];
|
||||
A[1] = A[6];
|
||||
A[6] = A[9];
|
||||
A[9] = A[22];
|
||||
A[22] = A[14];
|
||||
A[14] = A[20];
|
||||
A[20] = A[2];
|
||||
A[2] = A[12];
|
||||
A[12] = A[13];
|
||||
A[13] = A[19];
|
||||
A[19] = A[23];
|
||||
A[23] = A[15];
|
||||
A[15] = A[4];
|
||||
A[4] = A[24];
|
||||
A[24] = A[21];
|
||||
A[21] = A[8];
|
||||
A[8] = A[16];
|
||||
A[16] = A[5];
|
||||
A[5] = A[3];
|
||||
A[3] = A[18];
|
||||
A[18] = A[17];
|
||||
A[17] = A[11];
|
||||
A[11] = A[7];
|
||||
A[7] = A[10];
|
||||
A[10] = a1;
|
||||
}
|
||||
|
||||
private static void Chi(ulong[] A)
|
||||
{
|
||||
ulong chiC0, chiC1, chiC2, chiC3, chiC4;
|
||||
|
||||
for (int yBy5 = 0; yBy5 < 25; yBy5 += 5)
|
||||
{
|
||||
chiC0 = A[0 + yBy5] ^ ~A[(0 + 1) % 5 + yBy5] & A[(0 + 2) % 5 + yBy5];
|
||||
chiC1 = A[1 + yBy5] ^ ~A[(1 + 1) % 5 + yBy5] & A[(1 + 2) % 5 + yBy5];
|
||||
chiC2 = A[2 + yBy5] ^ ~A[(2 + 1) % 5 + yBy5] & A[(2 + 2) % 5 + yBy5];
|
||||
chiC3 = A[3 + yBy5] ^ ~A[(3 + 1) % 5 + yBy5] & A[(3 + 2) % 5 + yBy5];
|
||||
chiC4 = A[4 + yBy5] ^ ~A[(4 + 1) % 5 + yBy5] & A[(4 + 2) % 5 + yBy5];
|
||||
|
||||
A[0 + yBy5] = chiC0;
|
||||
A[1 + yBy5] = chiC1;
|
||||
A[2 + yBy5] = chiC2;
|
||||
A[3 + yBy5] = chiC3;
|
||||
A[4 + yBy5] = chiC4;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Iota(ulong[] A, int indexRound)
|
||||
{
|
||||
A[0] ^= _keccakRoundConstants[indexRound];
|
||||
}
|
||||
|
||||
private void PadAndSwitchToSqueezingPhase()
|
||||
{
|
||||
Debug.Assert(_bitsInQueue < _rate);
|
||||
|
||||
_dataQueue[_bitsInQueue >> 3] |= (byte)(1U << (_bitsInQueue & 7));
|
||||
|
||||
if (++_bitsInQueue == _rate)
|
||||
{
|
||||
KeccakAbsorb(_dataQueue, 0);
|
||||
_bitsInQueue = 0;
|
||||
}
|
||||
|
||||
{
|
||||
int full = _bitsInQueue >> 6, partial = _bitsInQueue & 63;
|
||||
int off = 0;
|
||||
for (int i = 0; i < full; ++i)
|
||||
{
|
||||
_state[i] ^= Pack.LeToUInt64(_dataQueue, off);
|
||||
off += 8;
|
||||
}
|
||||
|
||||
if (partial > 0)
|
||||
{
|
||||
ulong mask = (1UL << partial) - 1UL;
|
||||
_state[full] ^= Pack.LeToUInt64(_dataQueue, off) & mask;
|
||||
}
|
||||
|
||||
_state[_rate - 1 >> 6] ^= 1UL << 63;
|
||||
}
|
||||
|
||||
KeccakPermutation();
|
||||
KeccakExtract();
|
||||
_bitsAvailableForSqueezing = _rate;
|
||||
|
||||
_bitsInQueue = 0;
|
||||
_squeezing = true;
|
||||
}
|
||||
private void KeccakExtract()
|
||||
{
|
||||
Pack.UInt64ToLe(_state, 0, _rate >> 6, _dataQueue, 0);
|
||||
}
|
||||
|
||||
static class Pack
|
||||
{
|
||||
internal static ulong LeToUInt64(byte[] bs, int off)
|
||||
{
|
||||
uint lo = LeToUInt32(bs, off);
|
||||
uint hi = LeToUInt32(bs, off + 4);
|
||||
return (ulong)hi << 32 | lo;
|
||||
}
|
||||
internal static uint LeToUInt32(byte[] bs, int off)
|
||||
{
|
||||
return bs[off]
|
||||
| (uint)bs[off + 1] << 8
|
||||
| (uint)bs[off + 2] << 16
|
||||
| (uint)bs[off + 3] << 24;
|
||||
}
|
||||
|
||||
internal static void UInt64ToLe(ulong[] ns, int nsOff, int nsLen, byte[] bs, int bsOff)
|
||||
{
|
||||
for (int i = 0; i < nsLen; ++i)
|
||||
{
|
||||
UInt64ToLe(ns[nsOff + i], bs, bsOff);
|
||||
bsOff += 8;
|
||||
}
|
||||
}
|
||||
internal static void UInt64ToLe(ulong n, byte[] bs, int off)
|
||||
{
|
||||
UInt32ToLe((uint)n, bs, off);
|
||||
UInt32ToLe((uint)(n >> 32), bs, off + 4);
|
||||
}
|
||||
|
||||
internal static void UInt32ToLe(uint n, byte[] bs, int off)
|
||||
{
|
||||
bs[off] = (byte)n;
|
||||
bs[off + 1] = (byte)(n >> 8);
|
||||
bs[off + 2] = (byte)(n >> 16);
|
||||
bs[off + 3] = (byte)(n >> 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace CryptoExchange.Net.Caching
|
||||
{
|
||||
internal class MemoryCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheItem> _cache = new ConcurrentDictionary<string, CacheItem>();
|
||||
#if NET9_0_OR_GREATER
|
||||
private readonly Lock _lock = new Lock();
|
||||
#else
|
||||
private readonly object _lock = new object();
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Add a new cache entry. Will override an existing entry if it already exists
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.SharedApis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -13,6 +12,11 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
public abstract class BaseApiClient : IDisposable, IBaseApiClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Client name
|
||||
/// </summary>
|
||||
protected string? _clientName;
|
||||
|
||||
/// <summary>
|
||||
/// Logger
|
||||
/// </summary>
|
||||
@@ -24,9 +28,24 @@ namespace CryptoExchange.Net.Clients
|
||||
protected bool _disposing;
|
||||
|
||||
/// <summary>
|
||||
/// The authentication provider for this API client. (null if no credentials are set)
|
||||
/// Whether a proxy is configured
|
||||
/// </summary>
|
||||
public AuthenticationProvider? AuthenticationProvider { get; private set; }
|
||||
protected bool _proxyConfigured;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the client
|
||||
/// </summary>
|
||||
protected internal string ClientName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_clientName != null)
|
||||
return _clientName;
|
||||
|
||||
_clientName = GetType().Name;
|
||||
return _clientName;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The environment this client communicates to
|
||||
@@ -38,12 +57,6 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
public bool OutputOriginalData { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Authenticated => ApiCredentials != null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ApiCredentials? ApiCredentials { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Api options
|
||||
/// </summary>
|
||||
@@ -54,16 +67,25 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
public ExchangeOptions ClientOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Mapping of a response code to known error types
|
||||
/// </summary>
|
||||
protected internal virtual ErrorMapping ErrorMapping { get; } = new ErrorMapping([]);
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger</param>
|
||||
/// <param name="outputOriginalData">Should data from this client include the original data in the call result</param>
|
||||
/// <param name="baseAddress">Base address for this API client</param>
|
||||
/// <param name="apiCredentials">Api credentials</param>
|
||||
/// <param name="clientOptions">Client options</param>
|
||||
/// <param name="apiOptions">Api options</param>
|
||||
protected BaseApiClient(ILogger logger, bool outputOriginalData, ApiCredentials? apiCredentials, string baseAddress, ExchangeOptions clientOptions, ApiOptions apiOptions)
|
||||
protected BaseApiClient(
|
||||
ILogger logger,
|
||||
bool outputOriginalData,
|
||||
string baseAddress,
|
||||
ExchangeOptions clientOptions,
|
||||
ApiOptions apiOptions)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
@@ -71,40 +93,22 @@ namespace CryptoExchange.Net.Clients
|
||||
ApiOptions = apiOptions;
|
||||
OutputOriginalData = outputOriginalData;
|
||||
BaseAddress = baseAddress;
|
||||
ApiCredentials = apiCredentials?.Copy();
|
||||
|
||||
if (ApiCredentials != null)
|
||||
AuthenticationProvider = CreateAuthenticationProvider(ApiCredentials);
|
||||
_proxyConfigured = ClientOptions.Proxy != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an AuthenticationProvider implementation instance based on the provided credentials
|
||||
/// </summary>
|
||||
/// <param name="credentials"></param>
|
||||
/// <returns></returns>
|
||||
protected abstract AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials);
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetApiCredentials<T>(T credentials) where T : ApiCredentials
|
||||
{
|
||||
ApiCredentials = credentials?.Copy();
|
||||
if (ApiCredentials != null)
|
||||
AuthenticationProvider = CreateAuthenticationProvider(ApiCredentials);
|
||||
}
|
||||
/// <summary>
|
||||
/// Get error info for a response code
|
||||
/// </summary>
|
||||
public ErrorInfo GetErrorInfo(int code, string? message = null) => GetErrorInfo(code.ToString(), message);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void SetOptions<T>(UpdateOptions<T> options) where T : ApiCredentials
|
||||
{
|
||||
ClientOptions.Proxy = options.Proxy;
|
||||
ClientOptions.RequestTimeout = options.RequestTimeout ?? ClientOptions.RequestTimeout;
|
||||
|
||||
ApiCredentials = options.ApiCredentials?.Copy() ?? ApiCredentials;
|
||||
if (ApiCredentials != null)
|
||||
AuthenticationProvider = CreateAuthenticationProvider(ApiCredentials);
|
||||
}
|
||||
/// <summary>
|
||||
/// Get error info for a response code
|
||||
/// </summary>
|
||||
public ErrorInfo GetErrorInfo(string code, string? message = null) => ErrorMapping.GetErrorInfo(code.ToString(), message);
|
||||
|
||||
/// <summary>
|
||||
/// Dispose
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
@@ -39,6 +39,11 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
public string Exchange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether client is disposed
|
||||
/// </summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
@@ -49,7 +54,11 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
protected internal ILogger _logger;
|
||||
|
||||
#if NET9_0_OR_GREATER
|
||||
private readonly Lock _versionLock = new Lock();
|
||||
#else
|
||||
private readonly object _versionLock = new object();
|
||||
#endif
|
||||
private Version _exchangeVersion;
|
||||
|
||||
/// <summary>
|
||||
@@ -83,16 +92,6 @@ namespace CryptoExchange.Net.Clients
|
||||
_logger.Log(LogLevel.Trace, $"Client configuration: {options}, CryptoExchange.Net: v{CryptoExchangeLibVersion}, {Exchange}.Net: v{ExchangeLibVersion}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
|
||||
/// </summary>
|
||||
/// <param name="credentials">The credentials to set</param>
|
||||
protected virtual void SetApiCredentials<T>(T credentials) where T : ApiCredentials
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetApiCredentials(credentials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register an API client
|
||||
/// </summary>
|
||||
@@ -102,7 +101,6 @@ namespace CryptoExchange.Net.Clients
|
||||
if (ClientOptions == null)
|
||||
throw new InvalidOperationException("Client should have called Initialize before adding API clients");
|
||||
|
||||
_logger.Log(LogLevel.Trace, $" {apiClient.GetType().Name}, base address: {apiClient.BaseAddress}");
|
||||
ApiClients.Add(apiClient);
|
||||
return apiClient;
|
||||
}
|
||||
@@ -122,7 +120,8 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_logger.Log(LogLevel.Debug, "Disposing client");
|
||||
Disposed = true;
|
||||
|
||||
foreach (var client in ApiClients)
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Linq;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
@@ -10,6 +14,11 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
public abstract class BaseRestClient : BaseClient, IRestClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
internal new List<RestApiClient> ApiClients => base.ApiClients.OfType<RestApiClient>().ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public int TotalRequestsMade => ApiClients.OfType<RestApiClient>().Sum(s => s.TotalRequestsMade);
|
||||
|
||||
@@ -21,6 +30,62 @@ namespace CryptoExchange.Net.Clients
|
||||
protected BaseRestClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
_logger = loggerFactory?.CreateLogger(name + ".RestClient") ?? NullLoggerFactory.Instance.CreateLogger(name);
|
||||
|
||||
LibraryHelpers.StaticLogger = loggerFactory?.CreateLogger("CryptoExchange");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update options
|
||||
/// </summary>
|
||||
public virtual void SetOptions(UpdateOptions options)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class BaseRestClient<TEnvironment, TApiCredentials> : BaseRestClient, IRestClient<TApiCredentials>
|
||||
where TEnvironment : TradeEnvironment
|
||||
where TApiCredentials : ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
internal new List<RestApiClient<TEnvironment, TApiCredentials>> ApiClients => base.ApiClients.OfType<RestApiClient<TEnvironment, TApiCredentials>>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Provided client options
|
||||
/// </summary>
|
||||
public new RestExchangeOptions<TEnvironment, TApiCredentials> ClientOptions => (RestExchangeOptions<TEnvironment, TApiCredentials>)base.ClientOptions;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory</param>
|
||||
/// <param name="name">The name of the API this client is for</param>
|
||||
protected BaseRestClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
|
||||
/// </summary>
|
||||
/// <param name="credentials">The credentials to set</param>
|
||||
public void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetApiCredentials(credentials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update options
|
||||
/// </summary>
|
||||
public virtual void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
using System;
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Logging.Extensions;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Logging.Extensions;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
@@ -19,6 +21,11 @@ namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
#region fields
|
||||
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
internal new List<SocketApiClient> ApiClients => base.ApiClients.OfType<SocketApiClient>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// If client is disposing
|
||||
/// </summary>
|
||||
@@ -30,6 +37,9 @@ namespace CryptoExchange.Net.Clients
|
||||
public int CurrentSubscriptions => ApiClients.OfType<SocketApiClient>().Sum(s => s.CurrentSubscriptions);
|
||||
/// <inheritdoc />
|
||||
public double IncomingKbps => ApiClients.OfType<SocketApiClient>().Sum(s => s.IncomingKbps);
|
||||
|
||||
/// <inheritdoc />
|
||||
public new SocketExchangeOptions ClientOptions => (SocketExchangeOptions)base.ClientOptions;
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
@@ -40,6 +50,8 @@ namespace CryptoExchange.Net.Clients
|
||||
protected BaseSocketClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
_logger = loggerFactory?.CreateLogger(name + ".SocketClient") ?? NullLoggerFactory.Instance.CreateLogger(name);
|
||||
|
||||
LibraryHelpers.StaticLogger = loggerFactory?.CreateLogger("CryptoExchange");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -128,5 +140,58 @@ namespace CryptoExchange.Net.Clients
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update options
|
||||
/// </summary>
|
||||
public virtual void SetOptions(UpdateOptions options)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class BaseSocketClient<TEnvironment, TApiCredentials> : BaseSocketClient, ISocketClient<TApiCredentials>
|
||||
where TEnvironment : TradeEnvironment
|
||||
where TApiCredentials : ApiCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// Api clients in this client
|
||||
/// </summary>
|
||||
internal new List<SocketApiClient<TEnvironment, TApiCredentials>> ApiClients => base.ApiClients.OfType<SocketApiClient<TEnvironment, TApiCredentials>>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Provided client options
|
||||
/// </summary>
|
||||
public new SocketExchangeOptions<TEnvironment, TApiCredentials> ClientOptions => (SocketExchangeOptions<TEnvironment, TApiCredentials>)base.ClientOptions;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory</param>
|
||||
/// <param name="name">The name of the API this client is for</param>
|
||||
protected BaseSocketClient(ILoggerFactory? loggerFactory, string name) : base(loggerFactory, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
|
||||
/// </summary>
|
||||
/// <param name="credentials">The credentials to set</param>
|
||||
public virtual void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetApiCredentials(credentials);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update options
|
||||
/// </summary>
|
||||
public virtual void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
foreach (var apiClient in ApiClients)
|
||||
apiClient.SetOptions(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <summary>
|
||||
/// Base crypto client
|
||||
/// </summary>
|
||||
public class CryptoBaseClient : IDisposable
|
||||
{
|
||||
private readonly Dictionary<Type, object> _serviceCache = new Dictionary<Type, object>();
|
||||
|
||||
/// <summary>
|
||||
/// Service provider
|
||||
/// </summary>
|
||||
protected readonly IServiceProvider? _serviceProvider;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public CryptoBaseClient() { }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider"></param>
|
||||
public CryptoBaseClient(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_serviceCache = new Dictionary<Type, object>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try get a client by type for the service collection
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <returns></returns>
|
||||
public T TryGet<T>(Func<T> createFunc)
|
||||
{
|
||||
var type = typeof(T);
|
||||
if (_serviceCache.TryGetValue(type, out var value))
|
||||
return (T)value;
|
||||
|
||||
if (_serviceProvider == null)
|
||||
{
|
||||
// Create with default options
|
||||
var createResult = createFunc();
|
||||
_serviceCache.Add(typeof(T), createResult!);
|
||||
return createResult;
|
||||
}
|
||||
|
||||
var result = _serviceProvider.GetService<T>()
|
||||
?? throw new InvalidOperationException($"No service was found for {typeof(T).Name}, make sure the exchange is registered in dependency injection with the `services.Add[Exchange]()` method");
|
||||
_serviceCache.Add(type, result!);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_serviceCache.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class CryptoRestClient : CryptoBaseClient, ICryptoRestClient
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public CryptoRestClient()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider"></param>
|
||||
public CryptoRestClient(IServiceProvider serviceProvider) : base(serviceProvider)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class CryptoSocketClient : CryptoBaseClient, ICryptoSocketClient
|
||||
{
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public CryptoSocketClient()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider"></param>
|
||||
public CryptoSocketClient(IServiceProvider serviceProvider) : base(serviceProvider)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Caching;
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Logging.Extensions;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using CryptoExchange.Net.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
@@ -5,17 +18,10 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CryptoExchange.Net.Caching;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Logging.Extensions;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using CryptoExchange.Net.Requests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
@@ -27,12 +33,6 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <inheritdoc />
|
||||
public IRequestFactory RequestFactory { get; set; } = new RequestFactory();
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract TimeSyncInfo? GetTimeSyncInfo();
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract TimeSpan? GetTimeOffset();
|
||||
|
||||
/// <inheritdoc />
|
||||
public int TotalRequestsMade { get; set; }
|
||||
|
||||
@@ -54,7 +54,7 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <summary>
|
||||
/// Request headers to be sent with each request
|
||||
/// </summary>
|
||||
protected Dictionary<string, string>? StandardRequestHeaders { get; set; }
|
||||
protected Dictionary<string, string> StandardRequestHeaders { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether parameters need to be ordered
|
||||
@@ -78,6 +78,16 @@ namespace CryptoExchange.Net.Clients
|
||||
{ new HttpMethod("Patch"), HttpMethodParameterPosition.InBody },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Encoding/charset for the ContentType header
|
||||
/// </summary>
|
||||
protected Encoding? RequestBodyContentEncoding { get; set; } = Encoding.UTF8;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to omit the ContentType header if there is no content
|
||||
/// </summary>
|
||||
protected bool OmitContentTypeHeaderWithoutContent { get; set; } = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public new RestExchangeOptions ClientOptions => (RestExchangeOptions)base.ClientOptions;
|
||||
|
||||
@@ -89,6 +99,21 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
private readonly static MemoryCache _cache = new MemoryCache();
|
||||
|
||||
/// <summary>
|
||||
/// The message handler
|
||||
/// </summary>
|
||||
protected abstract IRestMessageHandler MessageHandler { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Get the AuthenticationProvider implementation, or null if no ApiCredentials are set
|
||||
/// </summary>
|
||||
public virtual AuthenticationProvider? GetAuthenticationProvider() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Configured environment name
|
||||
/// </summary>
|
||||
public abstract string EnvironmentName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
@@ -97,22 +122,21 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <param name="baseAddress">Base address for this API client</param>
|
||||
/// <param name="options">The base client options</param>
|
||||
/// <param name="apiOptions">The Api client options</param>
|
||||
public RestApiClient(ILogger logger, HttpClient? httpClient, string baseAddress, RestExchangeOptions options, RestApiOptions apiOptions)
|
||||
public RestApiClient(ILogger logger,
|
||||
HttpClient? httpClient,
|
||||
string baseAddress,
|
||||
RestExchangeOptions options,
|
||||
RestApiOptions apiOptions)
|
||||
: base(logger,
|
||||
apiOptions.OutputOriginalData ?? options.OutputOriginalData,
|
||||
apiOptions.ApiCredentials ?? options.ApiCredentials,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
RequestFactory.Configure(options.Proxy, options.RequestTimeout, httpClient);
|
||||
}
|
||||
TimeOffsetManager.RegisterRestApi(ClientName);
|
||||
|
||||
/// <summary>
|
||||
/// Create a message accessor instance
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected abstract IStreamMessageAccessor CreateAccessor();
|
||||
RequestFactory.Configure(options, httpClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a serializer instance
|
||||
@@ -203,6 +227,13 @@ namespace CryptoExchange.Net.Clients
|
||||
int? weightSingleLimiter = null,
|
||||
string? rateLimitKeySuffix = null)
|
||||
{
|
||||
var requestId = ExchangeHelpers.NextId();
|
||||
if (definition.Authenticated && GetAuthenticationProvider() == null)
|
||||
{
|
||||
_logger.RestApiNoApiCredentials(requestId, definition.Path);
|
||||
return new WebCallResult<T>(new NoApiCredentialsError());
|
||||
}
|
||||
|
||||
string? cacheKey = null;
|
||||
if (ShouldCache(definition))
|
||||
{
|
||||
@@ -223,11 +254,19 @@ namespace CryptoExchange.Net.Clients
|
||||
while (true)
|
||||
{
|
||||
currentTry++;
|
||||
var requestId = ExchangeHelpers.NextId();
|
||||
|
||||
var prepareResult = await PrepareAsync(requestId, baseAddress, definition, cancellationToken, additionalHeaders, weight, weightSingleLimiter, rateLimitKeySuffix).ConfigureAwait(false);
|
||||
if (!prepareResult)
|
||||
return new WebCallResult<T>(prepareResult.Error!);
|
||||
await CheckTimeSync(requestId, definition).ConfigureAwait(false);
|
||||
|
||||
var error = await RateLimitAsync(
|
||||
baseAddress,
|
||||
requestId,
|
||||
definition,
|
||||
weight ?? definition.Weight,
|
||||
cancellationToken,
|
||||
weightSingleLimiter,
|
||||
rateLimitKeySuffix).ConfigureAwait(false);
|
||||
if (error != null)
|
||||
return new WebCallResult<T>(error);
|
||||
|
||||
var request = CreateRequest(
|
||||
requestId,
|
||||
@@ -236,16 +275,24 @@ namespace CryptoExchange.Net.Clients
|
||||
uriParameters,
|
||||
bodyParameters,
|
||||
additionalHeaders);
|
||||
_logger.RestApiSendRequest(request.RequestId, definition, request.Content, string.IsNullOrEmpty(request.Uri.Query) ? "-" : request.Uri.Query, string.Join(", ", request.GetHeaders().Select(h => h.Key + $"=[{string.Join(",", h.Value)}]")));
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.RestApiSendRequest(request.RequestId, definition, request.Content, string.IsNullOrEmpty(request.Uri.Query) ? "-" : request.Uri.Query, string.Join(", ", request.GetHeaders().Select(h => h.Key + $"=[{string.Join(",", h.Value)}]")));
|
||||
TotalRequestsMade++;
|
||||
var result = await GetResponseAsync<T>(request, definition.RateLimitGate, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await GetResponseAsync2<T>(definition, request, definition.RateLimitGate, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Error is not CancellationRequestedError)
|
||||
{
|
||||
var originalData = OutputOriginalData ? result.OriginalData : "[Data only available when OutputOriginal = true]";
|
||||
if (!result)
|
||||
{
|
||||
_logger.RestApiErrorReceived(result.RequestId, result.ResponseStatusCode, (long)Math.Floor(result.ResponseTime!.Value.TotalMilliseconds), result.Error?.ToString(), originalData, result.Error?.Exception);
|
||||
}
|
||||
else
|
||||
_logger.RestApiResponseReceived(result.RequestId, result.ResponseStatusCode, (long)Math.Floor(result.ResponseTime!.Value.TotalMilliseconds), originalData);
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.RestApiResponseReceived(result.RequestId, result.ResponseStatusCode, (long)Math.Floor(result.ResponseTime!.Value.TotalMilliseconds), originalData);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -266,54 +313,19 @@ namespace CryptoExchange.Net.Clients
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepare before sending a request. Sync time between client and server and check rate limits
|
||||
/// Check rate limits for the request
|
||||
/// </summary>
|
||||
/// <param name="requestId">Request id</param>
|
||||
/// <param name="baseAddress">Host and schema</param>
|
||||
/// <param name="definition">Request definition</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <param name="additionalHeaders">Additional headers for this request</param>
|
||||
/// <param name="weight">Override the request weight for this request</param>
|
||||
/// <param name="weightSingleLimiter">Specify the weight to apply to the individual rate limit guard for this request</param>
|
||||
/// <param name="rateLimitKeySuffix">An additional optional suffix for the key selector</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception"></exception>
|
||||
protected virtual async Task<CallResult> PrepareAsync(
|
||||
protected virtual async ValueTask<Error?> RateLimitAsync(
|
||||
string host,
|
||||
int requestId,
|
||||
string baseAddress,
|
||||
RequestDefinition definition,
|
||||
int weight,
|
||||
CancellationToken cancellationToken,
|
||||
Dictionary<string, string>? additionalHeaders = null,
|
||||
int? weight = null,
|
||||
int? weightSingleLimiter = null,
|
||||
string? rateLimitKeySuffix = null)
|
||||
{
|
||||
// Time sync
|
||||
if (definition.Authenticated)
|
||||
{
|
||||
if (AuthenticationProvider == null)
|
||||
{
|
||||
_logger.RestApiNoApiCredentials(requestId, definition.Path);
|
||||
return new CallResult<IRequest>(new NoApiCredentialsError());
|
||||
}
|
||||
|
||||
var syncTask = SyncTimeAsync();
|
||||
var timeSyncInfo = GetTimeSyncInfo();
|
||||
|
||||
if (timeSyncInfo != null && timeSyncInfo.TimeSyncState.LastSyncTime == default)
|
||||
{
|
||||
// Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue
|
||||
var syncTimeResult = await syncTask.ConfigureAwait(false);
|
||||
if (!syncTimeResult)
|
||||
{
|
||||
_logger.RestApiFailedToSyncTime(requestId, syncTimeResult.Error!.ToString());
|
||||
return syncTimeResult.AsDataless();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
var requestWeight = weight ?? definition.Weight;
|
||||
var requestWeight = weight;
|
||||
if (requestWeight != 0)
|
||||
{
|
||||
if (definition.RateLimitGate == null)
|
||||
@@ -321,9 +333,19 @@ namespace CryptoExchange.Net.Clients
|
||||
|
||||
if (ClientOptions.RateLimiterEnabled)
|
||||
{
|
||||
var limitResult = await definition.RateLimitGate.ProcessAsync(_logger, requestId, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, requestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false);
|
||||
var limitResult = await definition.RateLimitGate.ProcessAsync(
|
||||
_logger,
|
||||
requestId,
|
||||
RateLimitItemType.Request,
|
||||
definition,
|
||||
host,
|
||||
GetAuthenticationProvider()?.Key,
|
||||
requestWeight,
|
||||
ClientOptions.RateLimitingBehaviour,
|
||||
rateLimitKeySuffix + ClientOptions.RateLimitGroup,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!limitResult)
|
||||
return new CallResult(limitResult.Error!);
|
||||
return limitResult.Error!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,13 +358,24 @@ namespace CryptoExchange.Net.Clients
|
||||
if (ClientOptions.RateLimiterEnabled)
|
||||
{
|
||||
var singleRequestWeight = weightSingleLimiter ?? 1;
|
||||
var limitResult = await definition.RateLimitGate.ProcessSingleAsync(_logger, requestId, definition.LimitGuard, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, singleRequestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false);
|
||||
var limitResult = await definition.RateLimitGate.ProcessSingleAsync(
|
||||
_logger,
|
||||
requestId,
|
||||
definition.LimitGuard,
|
||||
RateLimitItemType.Request,
|
||||
definition,
|
||||
host,
|
||||
GetAuthenticationProvider()?.Key,
|
||||
singleRequestWeight,
|
||||
ClientOptions.RateLimitingBehaviour,
|
||||
rateLimitKeySuffix,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!limitResult)
|
||||
return new CallResult(limitResult.Error!);
|
||||
return limitResult.Error!;
|
||||
}
|
||||
}
|
||||
|
||||
return CallResult.SuccessResult;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -363,74 +396,66 @@ namespace CryptoExchange.Net.Clients
|
||||
ParameterCollection? bodyParameters,
|
||||
Dictionary<string, string>? additionalHeaders)
|
||||
{
|
||||
var uriParams = uriParameters == null ? null : CreateParameterDictionary(uriParameters);
|
||||
var bodyParams = bodyParameters == null ? null : CreateParameterDictionary(bodyParameters);
|
||||
var requestConfiguration = new RestRequestConfiguration(
|
||||
definition,
|
||||
baseAddress,
|
||||
uriParameters == null ? null : CreateParameterDictionary(uriParameters),
|
||||
bodyParameters == null ? null : CreateParameterDictionary(bodyParameters),
|
||||
additionalHeaders,
|
||||
definition.ArraySerialization ?? ArraySerialization,
|
||||
definition.ParameterPosition ?? ParameterPositions[definition.Method],
|
||||
definition.RequestBodyFormat ?? RequestBodyFormat);
|
||||
|
||||
var uri = new Uri(baseAddress.AppendPath(definition.Path));
|
||||
var arraySerialization = definition.ArraySerialization ?? ArraySerialization;
|
||||
var bodyFormat = definition.RequestBodyFormat ?? RequestBodyFormat;
|
||||
var parameterPosition = definition.ParameterPosition ?? ParameterPositions[definition.Method];
|
||||
|
||||
Dictionary<string, string>? headers = null;
|
||||
if (AuthenticationProvider != null)
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
AuthenticationProvider.AuthenticateRequest(
|
||||
this,
|
||||
uri,
|
||||
definition.Method,
|
||||
ref uriParams,
|
||||
ref bodyParams,
|
||||
ref headers,
|
||||
definition.Authenticated,
|
||||
arraySerialization,
|
||||
parameterPosition,
|
||||
bodyFormat
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception("Failed to authenticate request, make sure your API credentials are correct", ex);
|
||||
}
|
||||
GetAuthenticationProvider()?.ProcessRequest(this, requestConfiguration);
|
||||
}
|
||||
|
||||
// Add the auth parameters to the uri, start with a new URI to be able to sort the parameters including the auth parameters
|
||||
if (uriParams != null)
|
||||
uri = uri.SetParameters(uriParams, arraySerialization);
|
||||
|
||||
var request = RequestFactory.Create(definition.Method, uri, requestId);
|
||||
request.Accept = Constants.JsonContentHeader;
|
||||
|
||||
if (headers != null)
|
||||
catch (Exception ex)
|
||||
{
|
||||
foreach (var header in headers)
|
||||
throw new Exception("Failed to authenticate request, make sure your API credentials are correct", ex);
|
||||
}
|
||||
|
||||
var queryString = requestConfiguration.GetQueryString(true);
|
||||
if (!string.IsNullOrEmpty(queryString) && !queryString.StartsWith("?"))
|
||||
queryString = $"?{queryString}";
|
||||
|
||||
var path = baseAddress.AppendPath(definition.Path);
|
||||
if (definition.ForcePathEndWithSlash == true && !path.EndsWith("/"))
|
||||
path += "/";
|
||||
|
||||
var uri = new Uri(path + queryString);
|
||||
var request = RequestFactory.Create(ClientOptions.HttpVersion, definition.Method, uri, requestId);
|
||||
request.Accept = MessageHandler.AcceptHeader;
|
||||
|
||||
if (requestConfiguration.Headers != null)
|
||||
{
|
||||
foreach (var header in requestConfiguration.Headers)
|
||||
request.AddHeader(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (additionalHeaders != null)
|
||||
foreach (var header in StandardRequestHeaders)
|
||||
{
|
||||
foreach (var header in additionalHeaders)
|
||||
// Only add it if it isn't overwritten
|
||||
requestConfiguration.Headers ??= new Dictionary<string, string>();
|
||||
if (!requestConfiguration.Headers.ContainsKey(header.Key))
|
||||
request.AddHeader(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (StandardRequestHeaders != null)
|
||||
if (requestConfiguration.ParameterPosition == HttpMethodParameterPosition.InBody)
|
||||
{
|
||||
foreach (var header in StandardRequestHeaders)
|
||||
var contentType = requestConfiguration.BodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader;
|
||||
var bodyContent = requestConfiguration.GetBodyContent();
|
||||
if (bodyContent != null)
|
||||
{
|
||||
// Only add it if it isn't overwritten
|
||||
if (additionalHeaders?.ContainsKey(header.Key) != true)
|
||||
request.AddHeader(header.Key, header.Value);
|
||||
request.SetContent(bodyContent, RequestBodyContentEncoding, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
if (parameterPosition == HttpMethodParameterPosition.InBody)
|
||||
{
|
||||
var contentType = bodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader;
|
||||
if (bodyParams != null && bodyParams.Count != 0)
|
||||
WriteParamBody(request, bodyParams, contentType);
|
||||
else
|
||||
request.SetContent(RequestBodyEmptyContent, contentType);
|
||||
{
|
||||
if (requestConfiguration.BodyParameters != null && requestConfiguration.BodyParameters.Count != 0)
|
||||
WriteParamBody(request, requestConfiguration.BodyParameters, contentType);
|
||||
else if (OmitContentTypeHeaderWithoutContent != true)
|
||||
request.SetContent(RequestBodyEmptyContent, RequestBodyContentEncoding, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
@@ -439,11 +464,13 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <summary>
|
||||
/// Executes the request and returns the result deserialized into the type parameter class
|
||||
/// </summary>
|
||||
/// <param name="requestDefinition">The request definition</param>
|
||||
/// <param name="request">The request object to execute</param>
|
||||
/// <param name="gate">The ratelimit gate used</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<WebCallResult<T>> GetResponseAsync<T>(
|
||||
protected virtual async Task<WebCallResult<T>> GetResponseAsync2<T>(
|
||||
RequestDefinition requestDefinition,
|
||||
IRequest request,
|
||||
IRateLimitGate? gate,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -451,27 +478,41 @@ namespace CryptoExchange.Net.Clients
|
||||
var sw = Stopwatch.StartNew();
|
||||
Stream? responseStream = null;
|
||||
IResponse? response = null;
|
||||
IStreamMessageAccessor? accessor = null;
|
||||
|
||||
try
|
||||
{
|
||||
response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
var statusCode = response.StatusCode;
|
||||
var headers = response.ResponseHeaders;
|
||||
var responseLength = response.ContentLength;
|
||||
responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false);
|
||||
responseStream = await response.GetResponseStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
string? originalData = null;
|
||||
var outputOriginalData = ApiOptions.OutputOriginalData ?? ClientOptions.OutputOriginalData;
|
||||
|
||||
accessor = CreateAccessor();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
if (outputOriginalData || MessageHandler.RequiresSeekableStream || !response.IsSuccessStatusCode)
|
||||
{
|
||||
// Error response
|
||||
var readResult = await accessor.Read(responseStream, true).ConfigureAwait(false);
|
||||
// Create a seekable stream from the response stream if:
|
||||
// 1. We need to output the original data
|
||||
// 2. The message handler requires a seekable stream
|
||||
// 3. The response indicates error and we want to output (part of) the returned data
|
||||
responseStream = await CopyStreamAsync(responseStream).ConfigureAwait(false);
|
||||
using var reader = new StreamReader(responseStream, Encoding.UTF8, false, 4096, true);
|
||||
if (outputOriginalData)
|
||||
{
|
||||
originalData = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
responseStream.Position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode && !requestDefinition.TryParseOnNonSuccess)
|
||||
{
|
||||
// If the response status is not success it is an error by definition
|
||||
|
||||
Error error;
|
||||
if (response.StatusCode == (HttpStatusCode)418 || response.StatusCode == (HttpStatusCode)429)
|
||||
{
|
||||
var rateError = ParseRateLimitResponse((int)response.StatusCode, response.ResponseHeaders, accessor);
|
||||
// Specifically handle rate limit errors
|
||||
var rateError = await MessageHandler.ParseErrorRateLimitResponse(
|
||||
(int)response.StatusCode,
|
||||
response.ResponseHeaders,
|
||||
responseStream).ConfigureAwait(false);
|
||||
if (rateError.RetryAfter != null && gate != null && ClientOptions.RateLimiterEnabled)
|
||||
{
|
||||
_logger.RestApiRateLimitPauseUntil(request.RequestId, rateError.RetryAfter.Value);
|
||||
@@ -482,29 +523,34 @@ namespace CryptoExchange.Net.Clients
|
||||
}
|
||||
else
|
||||
{
|
||||
error = ParseErrorResponse((int)response.StatusCode, response.ResponseHeaders, accessor, readResult.Error?.Exception);
|
||||
// Handle a 'normal' error response. Can still be either a json error message or some random HTML or other string
|
||||
try
|
||||
{
|
||||
error = await MessageHandler.ParseErrorResponse(
|
||||
(int)response.StatusCode,
|
||||
response.ResponseHeaders,
|
||||
responseStream).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception when parsing error response: {Message}", ex.Message);
|
||||
var errorResult = new ServerError(ErrorInfo.Unknown with { Message = ex.Message });
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, errorResult);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.Code == null || error.Code == 0)
|
||||
error.Code = (int)response.StatusCode;
|
||||
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error!);
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
|
||||
}
|
||||
|
||||
var valid = await accessor.Read(responseStream, outputOriginalData).ConfigureAwait(false);
|
||||
if (typeof(T) == typeof(object))
|
||||
// Success status code and expected empty response, assume it's correct
|
||||
return new WebCallResult<T>(statusCode, headers, sw.Elapsed, 0, accessor.OriginalDataAvailable ? accessor.GetOriginalString() : "[Data only available when OutputOriginal = true in client options]", request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, null);
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, 0, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, null);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
// Invalid json
|
||||
var error = new DeserializeError("Failed to parse response: " + valid.Error!.Message, valid.Error.Exception);
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
|
||||
}
|
||||
|
||||
// Json response received
|
||||
var parsedError = TryParseError(response.ResponseHeaders, accessor);
|
||||
// Data response received, inspect the message and check if it is an error or not
|
||||
var parsedError = await MessageHandler.CheckForErrorResponse(
|
||||
requestDefinition,
|
||||
response.ResponseHeaders,
|
||||
responseStream).ConfigureAwait(false);
|
||||
if (parsedError != null)
|
||||
{
|
||||
if (parsedError is ServerRateLimitError rateError)
|
||||
@@ -517,48 +563,84 @@ namespace CryptoExchange.Net.Clients
|
||||
}
|
||||
|
||||
// Success status code, but TryParseError determined it was an error response
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, parsedError);
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, parsedError);
|
||||
}
|
||||
|
||||
var deserializeResult = accessor.Deserialize<T>();
|
||||
return new WebCallResult<T>(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult.Data, deserializeResult.Error);
|
||||
if (MessageHandler.RequiresSeekableStream)
|
||||
// Reset stream read position as it might not be at the start if `CheckForErrorResponse` has read from it
|
||||
responseStream.Position = 0;
|
||||
|
||||
// Try deserialization into the expected type
|
||||
var (deserializeResult, deserializeError) = await MessageHandler.TryDeserializeAsync<T>(responseStream, cancellationToken).ConfigureAwait(false);
|
||||
if (deserializeError != null)
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, deserializeError); ;
|
||||
|
||||
try
|
||||
{
|
||||
// Check the deserialized response to see if it's an error or not
|
||||
var responseError = MessageHandler.CheckDeserializedResponse(response.ResponseHeaders, deserializeResult);
|
||||
if (responseError != null)
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, responseError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception when checking deserialized response: {Message}", ex.Message);
|
||||
var error = new ServerError(ErrorInfo.Unknown with { Message = ex.Message });
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, error);
|
||||
}
|
||||
|
||||
return new WebCallResult<T>(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, null);
|
||||
}
|
||||
catch (HttpRequestException requestException)
|
||||
{
|
||||
// Request exception, can't reach server for instance
|
||||
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new WebError(requestException.Message, exception: requestException));
|
||||
var error = new WebError(requestException.Message, requestException);
|
||||
return new WebCallResult<T>(null, null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
|
||||
}
|
||||
catch (OperationCanceledException canceledException)
|
||||
{
|
||||
if (cancellationToken != default && canceledException.CancellationToken == cancellationToken)
|
||||
{
|
||||
// Cancellation token canceled by caller
|
||||
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new CancellationRequestedError(canceledException));
|
||||
return new WebCallResult<T>(null, null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new CancellationRequestedError(canceledException));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Request timed out
|
||||
return new WebCallResult<T>(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new WebError($"Request timed out", exception: canceledException));
|
||||
var error = new WebError($"Request timed out", exception: canceledException);
|
||||
error.ErrorType = ErrorType.Timeout;
|
||||
return new WebCallResult<T>(null, null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException argumentException)
|
||||
{
|
||||
if (argumentException.Message.StartsWith("Only HTTP/"))
|
||||
{
|
||||
// Unsupported HTTP version error .net framework
|
||||
var error = ArgumentError.Invalid(nameof(RestExchangeOptions.HttpVersion), $"Invalid HTTP version {request.HttpVersion}: " + argumentException.Message);
|
||||
return new WebCallResult<T>(null, null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (NotSupportedException notSupportedException)
|
||||
{
|
||||
if (notSupportedException.Message.StartsWith("Request version value must be one of"))
|
||||
{
|
||||
// Unsupported HTTP version error dotnet code
|
||||
var error = ArgumentError.Invalid(nameof(RestExchangeOptions.HttpVersion), $"Invalid HTTP version {request.HttpVersion}: " + notSupportedException.Message);
|
||||
return new WebCallResult<T>(null, null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
accessor?.Clear();
|
||||
responseStream?.Close();
|
||||
response?.Close();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error.
|
||||
/// This method will be called for each response to be able to check if the response is an error or not.
|
||||
/// If the response is an error this method should return the parsed error, else it should return null
|
||||
/// </summary>
|
||||
/// <param name="accessor">Data accessor</param>
|
||||
/// <param name="responseHeaders">The response headers</param>
|
||||
/// <returns>Null if not an error, Error otherwise</returns>
|
||||
protected virtual Error? TryParseError(KeyValuePair<string, string[]>[] responseHeaders, IMessageAccessor accessor) => null;
|
||||
|
||||
/// <summary>
|
||||
/// Can be used to indicate that a request should be retried. Defaults to false. Make sure to retry a max number of times (based on the the tries parameter) or the request will retry forever.
|
||||
/// Note that this is always called; even when the request might be successful
|
||||
@@ -568,7 +650,7 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <param name="callResult">The result of the call</param>
|
||||
/// <param name="tries">The current try number</param>
|
||||
/// <returns>True if call should retry, false if the call should return</returns>
|
||||
protected virtual async Task<bool> ShouldRetryRequestAsync<T>(IRateLimitGate? gate, WebCallResult<T> callResult, int tries)
|
||||
protected virtual async ValueTask<bool> ShouldRetryRequestAsync<T>(IRateLimitGate? gate, WebCallResult<T> callResult, int tries)
|
||||
{
|
||||
if (tries >= 2)
|
||||
// Only retry once
|
||||
@@ -613,53 +695,16 @@ namespace CryptoExchange.Net.Clients
|
||||
stringData = stringSerializer.Serialize(value);
|
||||
else
|
||||
stringData = stringSerializer.Serialize(parameters);
|
||||
request.SetContent(stringData, contentType);
|
||||
request.SetContent(stringData, RequestBodyContentEncoding, contentType);
|
||||
}
|
||||
else if (contentType == Constants.FormContentHeader)
|
||||
{
|
||||
// Write the parameters as form data in the body
|
||||
var stringData = parameters.ToFormData();
|
||||
request.SetContent(stringData, contentType);
|
||||
request.SetContent(stringData, RequestBodyContentEncoding, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an error response from the server. Only used when server returns a status other than Success(200) or ratelimit error (429 or 418)
|
||||
/// </summary>
|
||||
/// <param name="httpStatusCode">The response status code</param>
|
||||
/// <param name="responseHeaders">The response headers</param>
|
||||
/// <param name="accessor">Data accessor</param>
|
||||
/// <param name="exception">Exception</param>
|
||||
/// <returns></returns>
|
||||
protected virtual Error ParseErrorResponse(int httpStatusCode, KeyValuePair<string, string[]>[] responseHeaders, IMessageAccessor accessor, Exception? exception)
|
||||
{
|
||||
return new ServerError(null, "Unknown request error", exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a rate limit error response from the server. Only used when server returns http status 429 or 418
|
||||
/// </summary>
|
||||
/// <param name="httpStatusCode">The response status code</param>
|
||||
/// <param name="responseHeaders">The response headers</param>
|
||||
/// <param name="accessor">Data accessor</param>
|
||||
/// <returns></returns>
|
||||
protected virtual ServerRateLimitError ParseRateLimitResponse(int httpStatusCode, KeyValuePair<string, string[]>[] responseHeaders, IMessageAccessor accessor)
|
||||
{
|
||||
// Handle retry after header
|
||||
var retryAfterHeader = responseHeaders.SingleOrDefault(r => r.Key.Equals("Retry-After", StringComparison.InvariantCultureIgnoreCase));
|
||||
if (retryAfterHeader.Value?.Any() != true)
|
||||
return new ServerRateLimitError();
|
||||
|
||||
var value = retryAfterHeader.Value.First();
|
||||
if (int.TryParse(value, out var seconds))
|
||||
return new ServerRateLimitError() { RetryAfter = DateTime.UtcNow.AddSeconds(seconds) };
|
||||
|
||||
if (DateTime.TryParse(value, out var datetime))
|
||||
return new ServerRateLimitError() { RetryAfter = datetime };
|
||||
|
||||
return new ServerRateLimitError();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create the parameter IDictionary
|
||||
/// </summary>
|
||||
@@ -679,34 +724,53 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <returns>Server time</returns>
|
||||
protected virtual Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetOptions<T>(UpdateOptions<T> options)
|
||||
private async ValueTask CheckTimeSync(int requestId, RequestDefinition definition)
|
||||
{
|
||||
base.SetOptions(options);
|
||||
if (!definition.Authenticated)
|
||||
return;
|
||||
|
||||
RequestFactory.UpdateSettings(options.Proxy, options.RequestTimeout ?? ClientOptions.RequestTimeout);
|
||||
var lastUpdateTime = TimeOffsetManager.GetRestLastUpdateTime(ClientName);
|
||||
var syncTask = CheckTimeOffsetAsync();
|
||||
|
||||
if (lastUpdateTime == null)
|
||||
{
|
||||
// Initially with first request we'll need to wait for the time syncing before making the actual request.
|
||||
// If it's not the first request we can just continue and let it complete in the background
|
||||
await syncTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
internal async Task<WebCallResult<bool>> SyncTimeAsync()
|
||||
internal async ValueTask CheckTimeOffsetAsync()
|
||||
{
|
||||
var timeSyncParams = GetTimeSyncInfo();
|
||||
if (timeSyncParams == null)
|
||||
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null);
|
||||
if (!(ApiOptions.AutoTimestamp ?? ClientOptions.AutoTimestamp))
|
||||
// Time syncing not enabled
|
||||
return;
|
||||
|
||||
if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false))
|
||||
await TimeOffsetManager.EnterAsync(ClientName).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!timeSyncParams.SyncTime || DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval)
|
||||
{
|
||||
timeSyncParams.TimeSyncState.Semaphore.Release();
|
||||
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null);
|
||||
}
|
||||
var lastUpdateTime = TimeOffsetManager.GetRestLastUpdateTime(ClientName);
|
||||
if (DateTime.UtcNow - lastUpdateTime < (ApiOptions.TimestampRecalculationInterval ?? ClientOptions.TimestampRecalculationInterval))
|
||||
// Time syncing was recently done
|
||||
return;
|
||||
|
||||
var localTime = DateTime.UtcNow;
|
||||
var result = await GetServerTimestampAsync().ConfigureAwait(false);
|
||||
WebCallResult<DateTime> result;
|
||||
try
|
||||
{
|
||||
result = await GetServerTimestampAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (NotImplementedException)
|
||||
{
|
||||
throw new ArgumentException("AutoTimestamp is not available for this API");
|
||||
}
|
||||
|
||||
if (!result)
|
||||
{
|
||||
timeSyncParams.TimeSyncState.Semaphore.Release();
|
||||
return result.As(false);
|
||||
_logger.LogWarning("Failed to determine time offset between client and server, timestamping might fail");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TotalRequestsMade == 1)
|
||||
@@ -716,23 +780,209 @@ namespace CryptoExchange.Net.Clients
|
||||
result = await GetServerTimestampAsync().ConfigureAwait(false);
|
||||
if (!result)
|
||||
{
|
||||
timeSyncParams.TimeSyncState.Semaphore.Release();
|
||||
return result.As(false);
|
||||
_logger.LogWarning("Failed to determine time offset between client and server, timestamping might fail");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate time offset between local and server
|
||||
// Estimate the offset as the round trip time / 2
|
||||
var offset = result.Data - localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2);
|
||||
timeSyncParams.UpdateTimeOffset(offset);
|
||||
timeSyncParams.TimeSyncState.Semaphore.Release();
|
||||
}
|
||||
if (offset.TotalMilliseconds > 0 && offset.TotalMilliseconds < 500)
|
||||
{
|
||||
_logger.LogInformation("{ClientName} Time offset within limits ({Offset}ms), set offset to 0ms", ClientName, Math.Round(offset.TotalMilliseconds));
|
||||
offset = TimeSpan.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("{ClientName} Time offset set to {Offset}ms", ClientName, Math.Round(offset.TotalMilliseconds));
|
||||
}
|
||||
|
||||
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null);
|
||||
TimeOffsetManager.UpdateRestOffset(ClientName, offset.TotalMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TimeOffsetManager.Release(ClientName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Stream> CopyStreamAsync(Stream responseStream)
|
||||
{
|
||||
var memoryStream = new MemoryStream();
|
||||
await responseStream.CopyToAsync(memoryStream).ConfigureAwait(false);
|
||||
responseStream.Close();
|
||||
memoryStream.Position = 0;
|
||||
return memoryStream;
|
||||
}
|
||||
|
||||
private bool ShouldCache(RequestDefinition definition)
|
||||
=> ClientOptions.CachingEnabled
|
||||
&& definition.Method == HttpMethod.Get
|
||||
&& !definition.PreventCaching;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void SetOptions(UpdateOptions options)
|
||||
{
|
||||
_proxyConfigured = options.Proxy != null;
|
||||
ClientOptions.Proxy = options.Proxy;
|
||||
ClientOptions.RequestTimeout = options.RequestTimeout ?? ClientOptions.RequestTimeout;
|
||||
|
||||
RequestFactory.UpdateSettings(options.Proxy, options.RequestTimeout ?? ClientOptions.RequestTimeout, ClientOptions.HttpKeepAliveInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class RestApiClient<TEnvironment> : RestApiClient, IRestApiClient
|
||||
where TEnvironment : TradeEnvironment
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public new RestExchangeOptions<TEnvironment> ClientOptions => (RestExchangeOptions<TEnvironment>)base.ClientOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string EnvironmentName => ClientOptions.Environment.Name;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected RestApiClient(
|
||||
ILogger logger,
|
||||
HttpClient? httpClient,
|
||||
string baseAddress,
|
||||
RestExchangeOptions options,
|
||||
RestApiOptions apiOptions) : base(
|
||||
logger,
|
||||
httpClient,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class RestApiClient<TEnvironment, TApiCredentials> : RestApiClient<TEnvironment>, IRestApiClient<TApiCredentials>
|
||||
where TApiCredentials : ApiCredentials
|
||||
where TEnvironment : TradeEnvironment
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public TApiCredentials? ApiCredentials { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Authenticated => ApiCredentials != null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public new RestExchangeOptions<TEnvironment, TApiCredentials> ClientOptions => (RestExchangeOptions<TEnvironment, TApiCredentials>)base.ClientOptions;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected RestApiClient(
|
||||
ILogger logger,
|
||||
HttpClient? httpClient,
|
||||
string baseAddress,
|
||||
RestExchangeOptions<TEnvironment, TApiCredentials> options,
|
||||
RestApiOptions apiOptions) : base(
|
||||
logger,
|
||||
httpClient,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
ApiCredentials = options.ApiCredentials;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
ApiCredentials = (TApiCredentials)credentials.Copy();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
base.SetOptions(options);
|
||||
|
||||
ApiCredentials = (TApiCredentials?)options.ApiCredentials?.Copy() ?? ApiCredentials;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class RestApiClient<TEnvironment, TAuthenticationProvider, TApiCredentials> : RestApiClient<TEnvironment, TApiCredentials>
|
||||
where TApiCredentials : ApiCredentials
|
||||
where TAuthenticationProvider : AuthenticationProvider<TApiCredentials>
|
||||
where TEnvironment : TradeEnvironment
|
||||
{
|
||||
|
||||
private bool _authProviderInitialized = false;
|
||||
private TAuthenticationProvider? _authenticationProvider;
|
||||
/// <summary>
|
||||
/// The authentication provider for this API client. (null if no credentials are set)
|
||||
/// </summary>
|
||||
public TAuthenticationProvider? AuthenticationProvider
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_authProviderInitialized)
|
||||
{
|
||||
if (ApiCredentials != null)
|
||||
_authenticationProvider = CreateAuthenticationProvider(ApiCredentials);
|
||||
|
||||
_authProviderInitialized = true;
|
||||
}
|
||||
|
||||
return _authenticationProvider;
|
||||
}
|
||||
internal set => _authenticationProvider = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override AuthenticationProvider? GetAuthenticationProvider() => AuthenticationProvider;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected RestApiClient(
|
||||
ILogger logger,
|
||||
HttpClient? httpClient,
|
||||
string baseAddress,
|
||||
RestExchangeOptions<TEnvironment, TApiCredentials> options,
|
||||
RestApiOptions apiOptions) : base(
|
||||
logger,
|
||||
httpClient,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an AuthenticationProvider implementation instance based on the provided credentials
|
||||
/// </summary>
|
||||
/// <param name="credentials"></param>
|
||||
/// <returns></returns>
|
||||
protected abstract TAuthenticationProvider CreateAuthenticationProvider(TApiCredentials credentials);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
base.SetApiCredentials(credentials);
|
||||
|
||||
AuthenticationProvider = null;
|
||||
_authProviderInitialized = false;
|
||||
ApiCredentials = credentials;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
base.SetOptions(options);
|
||||
|
||||
if (options.ApiCredentials != null)
|
||||
{
|
||||
AuthenticationProvider = null;
|
||||
_authProviderInitialized = false;
|
||||
ApiCredentials = options.ApiCredentials;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Interfaces;
|
||||
using CryptoExchange.Net.Interfaces.Clients;
|
||||
using CryptoExchange.Net.Logging.Extensions;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using CryptoExchange.Net.Objects.Options;
|
||||
using CryptoExchange.Net.Objects.Sockets;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using CryptoExchange.Net.Sockets;
|
||||
using CryptoExchange.Net.Sockets.Default;
|
||||
using CryptoExchange.Net.Sockets.Default.Interfaces;
|
||||
using CryptoExchange.Net.Sockets.HighPerf;
|
||||
using CryptoExchange.Net.Sockets.HighPerf.Interfaces;
|
||||
using CryptoExchange.Net.Sockets.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
@@ -24,13 +34,22 @@ namespace CryptoExchange.Net.Clients
|
||||
public abstract class SocketApiClient : BaseApiClient, ISocketApiClient
|
||||
{
|
||||
#region Fields
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IHighPerfConnectionFactory? HighPerfConnectionFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of socket connections currently connecting/connected
|
||||
/// </summary>
|
||||
protected internal ConcurrentDictionary<int, SocketConnection> socketConnections = new();
|
||||
protected internal ConcurrentDictionary<int, SocketConnection> _socketConnections = new();
|
||||
|
||||
/// <summary>
|
||||
/// List of HighPerf socket connections currently connecting/connected
|
||||
/// </summary>
|
||||
protected internal ConcurrentDictionary<int, HighPerfSocketConnection> _highPerfSocketConnections = new();
|
||||
|
||||
/// <summary>
|
||||
/// Semaphore used while creating sockets
|
||||
@@ -71,7 +90,7 @@ namespace CryptoExchange.Net.Clients
|
||||
/// Periodic task registrations
|
||||
/// </summary>
|
||||
protected List<PeriodicTaskRegistration> PeriodicTaskRegistrations { get; set; } = new List<PeriodicTaskRegistration>();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// List of address to keep an alive connection to
|
||||
/// </summary>
|
||||
@@ -82,35 +101,30 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
protected bool AllowTopicsOnTheSameConnection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to continue processing and forward unparsable messages to handlers
|
||||
/// </summary>
|
||||
protected internal bool ProcessUnparsableMessages { get; set; } = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public double IncomingKbps
|
||||
{
|
||||
get
|
||||
{
|
||||
if (socketConnections.IsEmpty)
|
||||
if (_socketConnections.IsEmpty)
|
||||
return 0;
|
||||
|
||||
return socketConnections.Sum(s => s.Value.IncomingKbps);
|
||||
return _socketConnections.Sum(s => s.Value.IncomingKbps);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CurrentConnections => socketConnections.Count;
|
||||
public int CurrentConnections => _socketConnections.Count;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CurrentSubscriptions
|
||||
{
|
||||
get
|
||||
{
|
||||
if (socketConnections.IsEmpty)
|
||||
if (_socketConnections.IsEmpty)
|
||||
return 0;
|
||||
|
||||
return socketConnections.Sum(s => s.Value.UserSubscriptionCount);
|
||||
return _socketConnections.Sum(s => s.Value.UserSubscriptionCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +134,25 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <inheritdoc />
|
||||
public new SocketApiOptions ApiOptions => (SocketApiOptions)base.ApiOptions;
|
||||
|
||||
/// <summary>
|
||||
/// The max number of individual subscriptions on a single connection
|
||||
/// </summary>
|
||||
public int? MaxIndividualSubscriptionsPerConnection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not to enforce that sequence number updates are always (lastSequenceNumber + 1)
|
||||
/// </summary>
|
||||
public bool EnforceSequenceNumbers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Get the AuthenticationProvider implementation, or null if no ApiCredentials are set
|
||||
/// </summary>
|
||||
public virtual AuthenticationProvider? GetAuthenticationProvider() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Configured environment name
|
||||
/// </summary>
|
||||
public abstract string EnvironmentName { get; }
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
@@ -129,22 +162,19 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <param name="options">Client options</param>
|
||||
/// <param name="baseAddress">Base address for this API client</param>
|
||||
/// <param name="apiOptions">The Api client options</param>
|
||||
public SocketApiClient(ILogger logger, string baseAddress, SocketExchangeOptions options, SocketApiOptions apiOptions)
|
||||
public SocketApiClient(
|
||||
ILogger logger,
|
||||
string baseAddress,
|
||||
SocketExchangeOptions options,
|
||||
SocketApiOptions apiOptions)
|
||||
: base(logger,
|
||||
apiOptions.OutputOriginalData ?? options.OutputOriginalData,
|
||||
apiOptions.ApiCredentials ?? options.ApiCredentials,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a message accessor instance
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected internal abstract IByteMessageAccessor CreateAccessor(WebSocketMessageType messageType);
|
||||
|
||||
/// <summary>
|
||||
/// Create a serializer instance
|
||||
/// </summary>
|
||||
@@ -161,6 +191,24 @@ namespace CryptoExchange.Net.Clients
|
||||
DedicatedConnectionConfigs.Add(new DedicatedConnectionConfig() { SocketAddress = url, Authenticated = auth });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the timestamp offset between client and server based on the timestamp
|
||||
/// </summary>
|
||||
/// <param name="timestamp">Timestamp received from the server</param>
|
||||
public virtual void UpdateTimeOffset(DateTime timestamp)
|
||||
{
|
||||
if (timestamp == default)
|
||||
return;
|
||||
|
||||
TimeOffsetManager.UpdateSocketOffset(ClientName, (DateTime.UtcNow - timestamp).TotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the time offset between client and server
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public virtual TimeSpan? GetTimeOffset() => TimeOffsetManager.GetSocketOffset(ClientName);
|
||||
|
||||
/// <summary>
|
||||
/// Add a query to periodically send on each connection
|
||||
/// </summary>
|
||||
@@ -168,7 +216,7 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <param name="interval"></param>
|
||||
/// <param name="queryDelegate"></param>
|
||||
/// <param name="callback"></param>
|
||||
protected virtual void RegisterPeriodicQuery(string identifier, TimeSpan interval, Func<SocketConnection, Query> queryDelegate, Action<SocketConnection, CallResult>? callback)
|
||||
protected virtual void RegisterPeriodicQuery(string identifier, TimeSpan interval, Func<ISocketConnection, Query> queryDelegate, Action<SocketConnection, CallResult>? callback)
|
||||
{
|
||||
PeriodicTaskRegistrations.Add(new PeriodicTaskRegistration
|
||||
{
|
||||
@@ -202,12 +250,15 @@ namespace CryptoExchange.Net.Clients
|
||||
if (_disposing)
|
||||
return new CallResult<UpdateSubscription>(new InvalidOperationError("Client disposed, can't subscribe"));
|
||||
|
||||
if (subscription.Authenticated && AuthenticationProvider == null)
|
||||
if (subscription.Authenticated && GetAuthenticationProvider() == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to subscribe, private subscription but no API credentials set");
|
||||
return new CallResult<UpdateSubscription>(new NoApiCredentialsError());
|
||||
}
|
||||
|
||||
if (subscription.IndividualSubscriptionCount > MaxIndividualSubscriptionsPerConnection)
|
||||
return new CallResult<UpdateSubscription>(ArgumentError.Invalid("subscriptions", $"Max number of subscriptions in a single call is {MaxIndividualSubscriptionsPerConnection}"));
|
||||
|
||||
SocketConnection socketConnection;
|
||||
var released = false;
|
||||
// Wait for a semaphore here, so we only connect 1 socket at a time.
|
||||
@@ -226,7 +277,7 @@ namespace CryptoExchange.Net.Clients
|
||||
while (true)
|
||||
{
|
||||
// Get a new or existing socket connection
|
||||
var socketResult = await GetSocketConnection(url, subscription.Authenticated, false, subscription.Topic).ConfigureAwait(false);
|
||||
var socketResult = await GetSocketConnection(url, subscription.Authenticated, false, ct, subscription.Topic, subscription.IndividualSubscriptionCount).ConfigureAwait(false);
|
||||
if (!socketResult)
|
||||
return socketResult.As<UpdateSubscription>(null);
|
||||
|
||||
@@ -265,48 +316,109 @@ namespace CryptoExchange.Net.Clients
|
||||
if (socketConnection.PausedActivity)
|
||||
{
|
||||
_logger.HasBeenPausedCantSubscribeAtThisMoment(socketConnection.SocketId);
|
||||
return new CallResult<UpdateSubscription>(new ServerError("Socket is paused"));
|
||||
return new CallResult<UpdateSubscription>(new ServerError(new ErrorInfo(ErrorType.WebsocketPaused, "Socket is paused")));
|
||||
}
|
||||
|
||||
var waitEvent = new AsyncResetEvent(false);
|
||||
var subQuery = subscription.GetSubQuery(socketConnection);
|
||||
if (subQuery != null)
|
||||
var subscribeResult = await socketConnection.TrySubscribeAsync(subscription, true, ct).ConfigureAwait(false);
|
||||
if (!subscribeResult)
|
||||
return new CallResult<UpdateSubscription>(subscribeResult.Error!);
|
||||
|
||||
_logger.SubscriptionCompletedSuccessfully(socketConnection.SocketId, subscription.Id);
|
||||
return new CallResult<UpdateSubscription>(new UpdateSubscription(socketConnection, subscription));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect to an url and listen for data
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to connect to</param>
|
||||
/// <param name="subscription">The subscription</param>
|
||||
/// <param name="connectionFactory">The factory for creating a socket connection</param>
|
||||
/// <param name="ct">Cancellation token for closing this subscription</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<HighPerfUpdateSubscription>> SubscribeHighPerfAsync<TUpdateType>(
|
||||
string url,
|
||||
HighPerfSubscription<TUpdateType> subscription,
|
||||
IHighPerfConnectionFactory connectionFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_disposing)
|
||||
return new CallResult<HighPerfUpdateSubscription>(new InvalidOperationError("Client disposed, can't subscribe"));
|
||||
|
||||
HighPerfSocketConnection<TUpdateType> socketConnection;
|
||||
var released = false;
|
||||
// Wait for a semaphore here, so we only connect 1 socket at a time.
|
||||
// This is necessary for being able to see if connections can be combined
|
||||
try
|
||||
{
|
||||
await semaphoreSlim.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException tce)
|
||||
{
|
||||
return new CallResult<HighPerfUpdateSubscription>(new CancellationRequestedError(tce));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
// Get a new or existing socket connection
|
||||
var socketResult = await GetHighPerfSocketConnection<TUpdateType>(url, connectionFactory, ct).ConfigureAwait(false);
|
||||
if (!socketResult)
|
||||
return socketResult.As<HighPerfUpdateSubscription>(null);
|
||||
|
||||
socketConnection = socketResult.Data;
|
||||
|
||||
// Add a subscription on the socket connection
|
||||
var success = socketConnection.AddSubscription(subscription);
|
||||
if (!success)
|
||||
{
|
||||
_logger.FailedToAddSubscriptionRetryOnDifferentConnection(socketConnection.SocketId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ClientOptions.SocketSubscriptionsCombineTarget == 1)
|
||||
{
|
||||
// Only 1 subscription per connection, so no need to wait for connection since a new subscription will create a new connection anyway
|
||||
semaphoreSlim.Release();
|
||||
released = true;
|
||||
}
|
||||
|
||||
var connectResult = await ConnectIfNeededAsync(socketConnection, false, ct).ConfigureAwait(false);
|
||||
if (!connectResult)
|
||||
return new CallResult<HighPerfUpdateSubscription>(connectResult.Error!);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!released)
|
||||
semaphoreSlim.Release();
|
||||
}
|
||||
|
||||
var subRequest = subscription.CreateSubscriptionQuery(socketConnection);
|
||||
if (subRequest != null)
|
||||
{
|
||||
// Send the request and wait for answer
|
||||
var subResult = await socketConnection.SendAndWaitQueryAsync(subQuery, waitEvent, ct).ConfigureAwait(false);
|
||||
if (!subResult)
|
||||
var sendResult = await socketConnection.SendAsync(subRequest).ConfigureAwait(false);
|
||||
if (!sendResult)
|
||||
{
|
||||
waitEvent?.Set();
|
||||
var isTimeout = subResult.Error is CancellationRequestedError;
|
||||
if (isTimeout && subscription.Confirmed)
|
||||
{
|
||||
// No response received, but the subscription did receive updates. We'll assume success
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.FailedToSubscribe(socketConnection.SocketId, subResult.Error?.ToString());
|
||||
// If this was a timeout we still need to send an unsubscribe to prevent messages coming in later
|
||||
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
|
||||
return new CallResult<UpdateSubscription>(subResult.Error!);
|
||||
}
|
||||
await socketConnection.CloseAsync().ConfigureAwait(false);
|
||||
return new CallResult<HighPerfUpdateSubscription>(sendResult.Error!);
|
||||
}
|
||||
|
||||
subscription.HandleSubQueryResponse(subQuery.Response!);
|
||||
}
|
||||
|
||||
subscription.Confirmed = true;
|
||||
if (ct != default)
|
||||
{
|
||||
subscription.CancellationTokenRegistration = ct.Register(async () =>
|
||||
{
|
||||
_logger.CancellationTokenSetClosingSubscription(socketConnection.SocketId, subscription.Id);
|
||||
await socketConnection.CloseAsync(subscription).ConfigureAwait(false);
|
||||
await socketConnection.CloseAsync().ConfigureAwait(false);
|
||||
}, false);
|
||||
}
|
||||
|
||||
waitEvent?.Set();
|
||||
_logger.SubscriptionCompletedSuccessfully(socketConnection.SocketId, subscription.Id);
|
||||
return new CallResult<UpdateSubscription>(new UpdateSubscription(socketConnection, subscription));
|
||||
return new CallResult<HighPerfUpdateSubscription>(new HighPerfUpdateSubscription(socketConnection, subscription));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -342,7 +454,7 @@ namespace CryptoExchange.Net.Clients
|
||||
await semaphoreSlim.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var socketResult = await GetSocketConnection(url, query.Authenticated, true).ConfigureAwait(false);
|
||||
var socketResult = await GetSocketConnection(url, query.Authenticated, true, ct).ConfigureAwait(false);
|
||||
if (!socketResult)
|
||||
return socketResult.As<THandlerResponse>(default);
|
||||
|
||||
@@ -368,13 +480,13 @@ namespace CryptoExchange.Net.Clients
|
||||
if (socketConnection.PausedActivity)
|
||||
{
|
||||
_logger.HasBeenPausedCantSendQueryAtThisMoment(socketConnection.SocketId);
|
||||
return new CallResult<THandlerResponse>(new ServerError("Socket is paused"));
|
||||
return new CallResult<THandlerResponse>(new ServerError(new ErrorInfo(ErrorType.WebsocketPaused, "Socket is paused")));
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
return new CallResult<THandlerResponse>(new CancellationRequestedError());
|
||||
|
||||
return await socketConnection.SendAndWaitQueryAsync(query, null, ct).ConfigureAwait(false);
|
||||
return await socketConnection.SendAndWaitQueryAsync(query, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -384,7 +496,7 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <param name="authenticated">Whether the socket should authenticated</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult> ConnectIfNeededAsync(SocketConnection socket, bool authenticated, CancellationToken ct)
|
||||
protected virtual async Task<CallResult> ConnectIfNeededAsync(ISocketConnection socket, bool authenticated, CancellationToken ct)
|
||||
{
|
||||
if (socket.Connected)
|
||||
return CallResult.SuccessResult;
|
||||
@@ -399,7 +511,10 @@ namespace CryptoExchange.Net.Clients
|
||||
if (!authenticated || socket.Authenticated)
|
||||
return CallResult.SuccessResult;
|
||||
|
||||
var result = await AuthenticateSocketAsync(socket).ConfigureAwait(false);
|
||||
if (socket is not SocketConnection sc)
|
||||
throw new InvalidOperationException("HighPerfSocketConnection not supported for authentication");
|
||||
|
||||
var result = await AuthenticateSocketAsync(sc).ConfigureAwait(false);
|
||||
if (!result)
|
||||
await socket.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -413,7 +528,7 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <returns></returns>
|
||||
public virtual async Task<CallResult> AuthenticateSocketAsync(SocketConnection socket)
|
||||
{
|
||||
if (AuthenticationProvider == null)
|
||||
if (GetAuthenticationProvider() == null)
|
||||
return new CallResult(new NoApiCredentialsError());
|
||||
|
||||
_logger.AttemptingToAuthenticate(socket.SocketId);
|
||||
@@ -443,7 +558,8 @@ namespace CryptoExchange.Net.Clients
|
||||
/// Should return the request which can be used to authenticate a socket connection
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected internal virtual Task<Query?> GetAuthenticationRequestAsync(SocketConnection connection) => throw new NotImplementedException();
|
||||
protected internal virtual Task<Query?> GetAuthenticationRequestAsync(SocketConnection connection) =>
|
||||
Task.FromResult(GetAuthenticationProvider()!.GetAuthenticationQuery(this, connection));
|
||||
|
||||
/// <summary>
|
||||
/// Adds a system subscription. Used for example to reply to ping requests
|
||||
@@ -452,7 +568,7 @@ namespace CryptoExchange.Net.Clients
|
||||
protected void AddSystemSubscription(SystemSubscription systemSubscription)
|
||||
{
|
||||
systemSubscriptions.Add(systemSubscription);
|
||||
foreach (var connection in socketConnections.Values)
|
||||
foreach (var connection in _socketConnections.Values)
|
||||
connection.AddSubscription(systemSubscription);
|
||||
}
|
||||
|
||||
@@ -472,7 +588,7 @@ namespace CryptoExchange.Net.Clients
|
||||
/// </summary>
|
||||
/// <param name="connection"></param>
|
||||
/// <returns></returns>
|
||||
protected internal virtual Task<Uri?> GetReconnectUriAsync(SocketConnection connection)
|
||||
protected internal virtual Task<Uri?> GetReconnectUriAsync(ISocketConnection connection)
|
||||
{
|
||||
return Task.FromResult<Uri?>(connection.ConnectionUri);
|
||||
}
|
||||
@@ -493,37 +609,95 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <param name="address">The address the socket is for</param>
|
||||
/// <param name="authenticated">Whether the socket should be authenticated</param>
|
||||
/// <param name="dedicatedRequestConnection">Whether a dedicated request connection should be returned</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <param name="topic">The subscription topic, can be provided when multiple of the same topics are not allowed on a connection</param>
|
||||
/// <param name="individualSubscriptionCount">The number of individual subscriptions in this subscribe request</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<SocketConnection>> GetSocketConnection(string address, bool authenticated, bool dedicatedRequestConnection, string? topic = null)
|
||||
protected virtual async Task<CallResult<SocketConnection>> GetSocketConnection(
|
||||
string address,
|
||||
bool authenticated,
|
||||
bool dedicatedRequestConnection,
|
||||
CancellationToken ct,
|
||||
string? topic = null,
|
||||
int individualSubscriptionCount = 1)
|
||||
{
|
||||
var socketQuery = socketConnections.Where(s => (s.Value.Status == SocketConnection.SocketStatus.None || s.Value.Status == SocketConnection.SocketStatus.Connected)
|
||||
&& s.Value.Tag.TrimEnd('/') == address.TrimEnd('/')
|
||||
var socketQuery = _socketConnections.Where(s => s.Value.Tag.TrimEnd('/') == address.TrimEnd('/')
|
||||
&& s.Value.ApiClient.GetType() == GetType()
|
||||
&& (s.Value.Authenticated == authenticated || !authenticated)
|
||||
&& (AllowTopicsOnTheSameConnection || !s.Value.Topics.Contains(topic))
|
||||
&& s.Value.Connected);
|
||||
&& (AllowTopicsOnTheSameConnection || !s.Value.Topics.Contains(topic)))
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
|
||||
SocketConnection connection;
|
||||
// If all current socket connections are reconnecting or resubscribing wait for that to finish as we can probably use the existing connection
|
||||
var delayStart = DateTime.UtcNow;
|
||||
var delayed = false;
|
||||
while (socketQuery.Count >= 1 && socketQuery.All(x => x.Status == SocketStatus.Reconnecting || x.Status == SocketStatus.Resubscribing))
|
||||
{
|
||||
if (DateTime.UtcNow - delayStart > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
if (socketQuery.Count >= 1 && socketQuery.All(x => x.Status == SocketStatus.Reconnecting || x.Status == SocketStatus.Resubscribing))
|
||||
{
|
||||
// If after this time we still trying to reconnect/reprocess there is some issue in the connection
|
||||
_logger.TimeoutWaitingForReconnectingSocket();
|
||||
return new CallResult<SocketConnection>(new CantConnectError());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
delayed = true;
|
||||
try { await Task.Delay(50, ct).ConfigureAwait(false); } catch (Exception) { }
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
return new CallResult<SocketConnection>(new CancellationRequestedError());
|
||||
}
|
||||
|
||||
if (delayed)
|
||||
_logger.WaitedForReconnectingSocket((long)(DateTime.UtcNow - delayStart).TotalMilliseconds);
|
||||
|
||||
socketQuery = socketQuery.Where(s => (s.Status == SocketStatus.None || s.Status == SocketStatus.Connected)
|
||||
&& (s.Authenticated == authenticated || !authenticated)
|
||||
&& s.Connected).ToList();
|
||||
|
||||
SocketConnection? connection;
|
||||
if (!dedicatedRequestConnection)
|
||||
{
|
||||
connection = socketQuery.Where(s => !s.Value.DedicatedRequestConnection.IsDedicatedRequestConnection).OrderBy(s => s.Value.UserSubscriptionCount).FirstOrDefault().Value;
|
||||
connection = socketQuery.Where(s => !s.DedicatedRequestConnection.IsDedicatedRequestConnection).OrderBy(s => s.UserSubscriptionCount).FirstOrDefault();
|
||||
}
|
||||
else
|
||||
{
|
||||
connection = socketQuery.Where(s => s.Value.DedicatedRequestConnection.IsDedicatedRequestConnection).FirstOrDefault().Value;
|
||||
connection = socketQuery.Where(s => s.DedicatedRequestConnection.IsDedicatedRequestConnection).FirstOrDefault();
|
||||
if (connection != null && !connection.DedicatedRequestConnection.Authenticated)
|
||||
// Mark dedicated request connection as authenticated if the request is authenticated
|
||||
connection.DedicatedRequestConnection.Authenticated = authenticated;
|
||||
|
||||
if (connection == null)
|
||||
// Fall back to an existing connection if there is no dedicated request connection available
|
||||
connection = socketQuery.OrderBy(s => s.UserSubscriptionCount).FirstOrDefault();
|
||||
}
|
||||
|
||||
bool maxConnectionsReached = _socketConnections.Count >= (ApiOptions.MaxSocketConnections ?? ClientOptions.MaxSocketConnections);
|
||||
if (connection != null)
|
||||
{
|
||||
if (connection.UserSubscriptionCount < ClientOptions.SocketSubscriptionsCombineTarget || (socketConnections.Count >= (ApiOptions.MaxSocketConnections ?? ClientOptions.MaxSocketConnections) && socketConnections.All(s => s.Value.UserSubscriptionCount >= ClientOptions.SocketSubscriptionsCombineTarget)))
|
||||
bool lessThanBatchSubCombineTarget = connection.UserSubscriptionCount < ClientOptions.SocketSubscriptionsCombineTarget;
|
||||
bool lessThanIndividualSubCombineTarget = connection.Subscriptions.Sum(x => x.IndividualSubscriptionCount) < ClientOptions.SocketIndividualSubscriptionCombineTarget;
|
||||
|
||||
if ((lessThanBatchSubCombineTarget && lessThanIndividualSubCombineTarget)
|
||||
|| maxConnectionsReached)
|
||||
{
|
||||
// Use existing socket if it has less than target connections OR it has the least connections and we can't make new
|
||||
return new CallResult<SocketConnection>(connection);
|
||||
// If there is a max subscriptions per connection limit also only use existing if the new subscription doesn't go over the limit
|
||||
if (MaxIndividualSubscriptionsPerConnection == null)
|
||||
return new CallResult<SocketConnection>(connection);
|
||||
|
||||
var currentCount = connection.Subscriptions.Sum(x => x.IndividualSubscriptionCount);
|
||||
if (currentCount + individualSubscriptionCount <= MaxIndividualSubscriptionsPerConnection)
|
||||
return new CallResult<SocketConnection>(connection);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxConnectionsReached)
|
||||
return new CallResult<SocketConnection>(new InvalidOperationError("Max amount of socket connections reached"));
|
||||
|
||||
var connectionAddress = await GetConnectionUrlAsync(address, authenticated).ConfigureAwait(false);
|
||||
if (!connectionAddress)
|
||||
{
|
||||
@@ -534,10 +708,8 @@ namespace CryptoExchange.Net.Clients
|
||||
if (connectionAddress.Data != address)
|
||||
_logger.ConnectionAddressSetTo(connectionAddress.Data!);
|
||||
|
||||
// Create new socket
|
||||
var socket = CreateSocket(connectionAddress.Data!);
|
||||
var socketConnection = new SocketConnection(_logger, this, socket, address);
|
||||
socketConnection.UnhandledMessage += HandleUnhandledMessage;
|
||||
// Create new socket connection
|
||||
var socketConnection = new SocketConnection(_logger, SocketFactory, GetWebSocketParameters(connectionAddress.Data!), this, address);
|
||||
socketConnection.ConnectRateLimitedAsync += HandleConnectRateLimitedAsync;
|
||||
if (dedicatedRequestConnection)
|
||||
{
|
||||
@@ -557,13 +729,44 @@ namespace CryptoExchange.Net.Clients
|
||||
return new CallResult<SocketConnection>(socketConnection);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets a connection for a new subscription or query. Can be an existing if there are open position or a new one.
|
||||
/// </summary>
|
||||
/// <param name="address">The address the socket is for</param>
|
||||
/// <param name="connectionFactory">The factory for creating a socket connection</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult<HighPerfSocketConnection<TUpdateType>>> GetHighPerfSocketConnection<TUpdateType>(
|
||||
string address,
|
||||
IHighPerfConnectionFactory connectionFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var connectionAddress = await GetConnectionUrlAsync(address, false).ConfigureAwait(false);
|
||||
if (!connectionAddress)
|
||||
{
|
||||
_logger.FailedToDetermineConnectionUrl(connectionAddress.Error?.ToString());
|
||||
return connectionAddress.As<HighPerfSocketConnection<TUpdateType>>(null);
|
||||
}
|
||||
|
||||
if (connectionAddress.Data != address)
|
||||
_logger.ConnectionAddressSetTo(connectionAddress.Data!);
|
||||
|
||||
// Create new socket connection
|
||||
var socketConnection = connectionFactory.CreateHighPerfConnection<TUpdateType>(_logger, SocketFactory, GetWebSocketParameters(connectionAddress.Data!), this, address);
|
||||
foreach (var ptg in PeriodicTaskRegistrations)
|
||||
socketConnection.QueryPeriodic(ptg.Identifier, ptg.Interval, (con) => ptg.QueryDelegate(con).Request);
|
||||
|
||||
return new CallResult<HighPerfSocketConnection<TUpdateType>>(socketConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process an unhandled message
|
||||
/// </summary>
|
||||
/// <param name="message">The message that wasn't processed</param>
|
||||
protected virtual void HandleUnhandledMessage(IMessageAccessor message)
|
||||
{
|
||||
}
|
||||
/// <param name="connection">The socket connection</param>
|
||||
/// <param name="typeIdentifier">The type as identified</param>
|
||||
/// <param name="data">The data</param>
|
||||
protected internal virtual bool HandleUnhandledMessage(SocketConnection connection, string typeIdentifier, ReadOnlySpan<byte> data) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Process connect rate limited
|
||||
@@ -585,12 +788,15 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <param name="socketConnection">The socket to connect</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns></returns>
|
||||
protected virtual async Task<CallResult> ConnectSocketAsync(SocketConnection socketConnection, CancellationToken ct)
|
||||
protected virtual async Task<CallResult> ConnectSocketAsync(ISocketConnection socketConnection, CancellationToken ct)
|
||||
{
|
||||
var connectResult = await socketConnection.ConnectAsync(ct).ConfigureAwait(false);
|
||||
if (connectResult)
|
||||
{
|
||||
socketConnections.TryAdd(socketConnection.SocketId, socketConnection);
|
||||
if (socketConnection is SocketConnection sc)
|
||||
_socketConnections.TryAdd(socketConnection.SocketId, sc);
|
||||
else if (socketConnection is HighPerfSocketConnection hsc)
|
||||
_highPerfSocketConnections.TryAdd(socketConnection.SocketId, hsc);
|
||||
return connectResult;
|
||||
}
|
||||
|
||||
@@ -616,18 +822,6 @@ namespace CryptoExchange.Net.Clients
|
||||
ReceiveBufferSize = ClientOptions.ReceiveBufferSize,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a socket for an address
|
||||
/// </summary>
|
||||
/// <param name="address">The address the socket should connect to</param>
|
||||
/// <returns></returns>
|
||||
protected virtual IWebsocket CreateSocket(string address)
|
||||
{
|
||||
var socket = SocketFactory.CreateWebsocket(_logger, GetWebSocketParameters(address));
|
||||
_logger.SocketCreatedForAddress(socket.Id, address);
|
||||
return socket;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribe an update subscription
|
||||
/// </summary>
|
||||
@@ -637,7 +831,7 @@ namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
Subscription? subscription = null;
|
||||
SocketConnection? connection = null;
|
||||
foreach (var socket in socketConnections.Values.ToList())
|
||||
foreach (var socket in _socketConnections.Values.ToList())
|
||||
{
|
||||
subscription = socket.GetSubscription(subscriptionId);
|
||||
if (subscription != null)
|
||||
@@ -675,21 +869,25 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <returns></returns>
|
||||
public virtual async Task UnsubscribeAllAsync()
|
||||
{
|
||||
var sum = socketConnections.Sum(s => s.Value.UserSubscriptionCount);
|
||||
var sum = _socketConnections.Sum(s => s.Value.UserSubscriptionCount) + _highPerfSocketConnections.Sum(s => s.Value.UserSubscriptionCount);
|
||||
if (sum == 0)
|
||||
return;
|
||||
|
||||
_logger.UnsubscribingAll(socketConnections.Sum(s => s.Value.UserSubscriptionCount));
|
||||
_logger.UnsubscribingAll(sum);
|
||||
var tasks = new List<Task>();
|
||||
|
||||
var socketList = _socketConnections.Values;
|
||||
foreach (var connection in socketList)
|
||||
{
|
||||
var socketList = socketConnections.Values;
|
||||
foreach (var connection in socketList)
|
||||
{
|
||||
foreach(var subscription in connection.Subscriptions.Where(x => x.UserSubscription))
|
||||
tasks.Add(connection.CloseAsync(subscription));
|
||||
}
|
||||
foreach(var subscription in connection.Subscriptions.Where(x => x.UserSubscription))
|
||||
tasks.Add(connection.CloseAsync(subscription));
|
||||
}
|
||||
|
||||
var highPerfSocketList = _highPerfSocketConnections.Values;
|
||||
foreach (var connection in highPerfSocketList)
|
||||
tasks.Add(connection.CloseAsync());
|
||||
|
||||
|
||||
await Task.WhenAll(tasks.ToArray()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -699,10 +897,10 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <returns></returns>
|
||||
public virtual async Task ReconnectAsync()
|
||||
{
|
||||
_logger.ReconnectingAllConnections(socketConnections.Count);
|
||||
_logger.ReconnectingAllConnections(_socketConnections.Count);
|
||||
var tasks = new List<Task>();
|
||||
{
|
||||
var socketList = socketConnections.Values;
|
||||
var socketList = _socketConnections.Values;
|
||||
foreach (var sub in socketList)
|
||||
tasks.Add(sub.TriggerReconnectAsync());
|
||||
}
|
||||
@@ -715,7 +913,7 @@ namespace CryptoExchange.Net.Clients
|
||||
{
|
||||
foreach (var item in DedicatedConnectionConfigs)
|
||||
{
|
||||
var socketResult = await GetSocketConnection(item.SocketAddress, item.Authenticated, true).ConfigureAwait(false);
|
||||
var socketResult = await GetSocketConnection(item.SocketAddress, item.Authenticated, true, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!socketResult)
|
||||
return socketResult.AsDataless();
|
||||
|
||||
@@ -727,25 +925,6 @@ namespace CryptoExchange.Net.Clients
|
||||
return CallResult.SuccessResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetOptions<T>(UpdateOptions<T> options)
|
||||
{
|
||||
var previousProxyIsSet = ClientOptions.Proxy != null;
|
||||
base.SetOptions(options);
|
||||
|
||||
if ((!previousProxyIsSet && options.Proxy == null)
|
||||
|| socketConnections.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Reconnecting websockets to apply proxy");
|
||||
|
||||
// Update proxy, also triggers reconnect
|
||||
foreach (var connection in socketConnections)
|
||||
_ = connection.Value.UpdateProxy(options.Proxy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log the current state of connections and subscriptions
|
||||
/// </summary>
|
||||
@@ -761,15 +940,15 @@ namespace CryptoExchange.Net.Clients
|
||||
/// <returns></returns>
|
||||
public SocketApiClientState GetState(bool includeSubDetails = true)
|
||||
{
|
||||
var connectionStates = new List<SocketConnection.SocketConnectionState>();
|
||||
foreach (var socketIdAndConnection in socketConnections)
|
||||
var connectionStates = new List<SocketConnectionState>();
|
||||
foreach (var socketIdAndConnection in _socketConnections)
|
||||
{
|
||||
SocketConnection connection = socketIdAndConnection.Value;
|
||||
SocketConnection.SocketConnectionState connectionState = connection.GetState(includeSubDetails);
|
||||
SocketConnectionState connectionState = connection.GetState(includeSubDetails);
|
||||
connectionStates.Add(connectionState);
|
||||
}
|
||||
|
||||
return new SocketApiClientState(socketConnections.Count, CurrentSubscriptions, IncomingKbps, connectionStates);
|
||||
return new SocketApiClientState(_socketConnections.Count, CurrentSubscriptions, IncomingKbps, connectionStates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -783,7 +962,7 @@ namespace CryptoExchange.Net.Clients
|
||||
int Connections,
|
||||
int Subscriptions,
|
||||
double DownloadSpeed,
|
||||
List<SocketConnection.SocketConnectionState> ConnectionStates)
|
||||
List<SocketConnectionState> ConnectionStates)
|
||||
{
|
||||
/// <summary>
|
||||
/// Print the state of the client
|
||||
@@ -812,9 +991,8 @@ namespace CryptoExchange.Net.Clients
|
||||
cs.SubscriptionStates.ForEach(subState =>
|
||||
{
|
||||
sb.AppendLine($"\t\t\tId: {subState.Id}");
|
||||
sb.AppendLine($"\t\t\tConfirmed: {subState.Confirmed}");
|
||||
sb.AppendLine($"\t\t\tStatus: {subState.Status}");
|
||||
sb.AppendLine($"\t\t\tInvocations: {subState.Invocations}");
|
||||
sb.AppendLine($"\t\t\tIdentifiers: [{subState.ListenMatcher.ToString()}]");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -831,7 +1009,7 @@ namespace CryptoExchange.Net.Clients
|
||||
_disposing = true;
|
||||
var tasks = new List<Task>();
|
||||
{
|
||||
var socketList = socketConnections.Values.Where(x => x.UserSubscriptionCount > 0 || x.Connected);
|
||||
var socketList = _socketConnections.Values.Where(x => x.UserSubscriptionCount > 0 || x.Connected);
|
||||
if (socketList.Any())
|
||||
_logger.DisposingSocketClient();
|
||||
|
||||
@@ -845,20 +1023,184 @@ namespace CryptoExchange.Net.Clients
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the listener identifier for the message
|
||||
/// </summary>
|
||||
/// <param name="messageAccessor"></param>
|
||||
/// <returns></returns>
|
||||
public abstract string? GetListenerIdentifier(IMessageAccessor messageAccessor);
|
||||
|
||||
/// <summary>
|
||||
/// Preprocess a stream message
|
||||
/// </summary>
|
||||
/// <param name="connection"></param>
|
||||
/// <param name="type"></param>
|
||||
/// <param name="data"></param>
|
||||
public virtual ReadOnlySpan<byte> PreprocessStreamMessage(SocketConnection connection, WebSocketMessageType type, ReadOnlySpan<byte> data) => data;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new message converter instance
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public virtual ReadOnlyMemory<byte> PreprocessStreamMessage(SocketConnection connection, WebSocketMessageType type, ReadOnlyMemory<byte> data) => data;
|
||||
public abstract ISocketMessageHandler CreateMessageConverter(WebSocketMessageType messageType);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void SetOptions(UpdateOptions options)
|
||||
{
|
||||
var previousProxyIsSet = _proxyConfigured;
|
||||
|
||||
ClientOptions.Proxy = options.Proxy;
|
||||
ClientOptions.RequestTimeout = options.RequestTimeout ?? ClientOptions.RequestTimeout;
|
||||
|
||||
_proxyConfigured = options.Proxy != null;
|
||||
if ((!previousProxyIsSet && options.Proxy == null)
|
||||
|| _socketConnections.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Reconnecting websockets to apply proxy");
|
||||
|
||||
// Update proxy, also triggers reconnect
|
||||
foreach (var connection in _socketConnections)
|
||||
_ = connection.Value.UpdateProxy(options.Proxy);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class SocketApiClient<TEnvironment> : SocketApiClient, ISocketApiClient
|
||||
where TEnvironment : TradeEnvironment
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public new SocketExchangeOptions<TEnvironment> ClientOptions => (SocketExchangeOptions<TEnvironment>)base.ClientOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string EnvironmentName => ClientOptions.Environment.Name;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected SocketApiClient(
|
||||
ILogger logger,
|
||||
string baseAddress,
|
||||
SocketExchangeOptions<TEnvironment> options,
|
||||
SocketApiOptions apiOptions) : base(
|
||||
logger,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class SocketApiClient<TEnvironment, TApiCredentials> : SocketApiClient<TEnvironment>, ISocketApiClient<TApiCredentials>
|
||||
where TApiCredentials : ApiCredentials
|
||||
where TEnvironment : TradeEnvironment
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public TApiCredentials? ApiCredentials { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Authenticated => ApiCredentials != null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public new SocketExchangeOptions<TEnvironment, TApiCredentials> ClientOptions => (SocketExchangeOptions<TEnvironment, TApiCredentials>)base.ClientOptions;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected SocketApiClient(
|
||||
ILogger logger,
|
||||
string baseAddress,
|
||||
SocketExchangeOptions<TEnvironment, TApiCredentials> options,
|
||||
SocketApiOptions apiOptions) : base(
|
||||
logger,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
ApiCredentials = options.ApiCredentials;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
ApiCredentials = (TApiCredentials)credentials.Copy();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
base.SetOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract class SocketApiClient<TEnvironment, TAuthenticationProvider, TApiCredentials> : SocketApiClient<TEnvironment, TApiCredentials>
|
||||
where TAuthenticationProvider : AuthenticationProvider<TApiCredentials>
|
||||
where TApiCredentials : ApiCredentials
|
||||
where TEnvironment : TradeEnvironment
|
||||
{
|
||||
|
||||
private bool _authProviderInitialized = false;
|
||||
private TAuthenticationProvider? _authenticationProvider;
|
||||
/// <summary>
|
||||
/// The authentication provider for this API client. (null if no credentials are set)
|
||||
/// </summary>
|
||||
public TAuthenticationProvider? AuthenticationProvider
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_authProviderInitialized)
|
||||
{
|
||||
if (ApiCredentials != null)
|
||||
_authenticationProvider = CreateAuthenticationProvider(ApiCredentials);
|
||||
|
||||
_authProviderInitialized = true;
|
||||
}
|
||||
|
||||
return _authenticationProvider;
|
||||
}
|
||||
internal set => _authenticationProvider = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override AuthenticationProvider? GetAuthenticationProvider() => AuthenticationProvider;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
protected SocketApiClient(
|
||||
ILogger logger,
|
||||
string baseAddress,
|
||||
SocketExchangeOptions<TEnvironment, TApiCredentials> options,
|
||||
SocketApiOptions apiOptions) : base(
|
||||
logger,
|
||||
baseAddress,
|
||||
options,
|
||||
apiOptions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an AuthenticationProvider implementation instance based on the provided credentials
|
||||
/// </summary>
|
||||
/// <param name="credentials"></param>
|
||||
/// <returns></returns>
|
||||
protected abstract TAuthenticationProvider CreateAuthenticationProvider(TApiCredentials credentials);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetApiCredentials(TApiCredentials credentials)
|
||||
{
|
||||
AuthenticationProvider = null;
|
||||
_authProviderInitialized = false;
|
||||
ApiCredentials = credentials;
|
||||
|
||||
base.SetApiCredentials(credentials);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetOptions(UpdateOptions<TApiCredentials> options)
|
||||
{
|
||||
if (options.ApiCredentials != null)
|
||||
{
|
||||
AuthenticationProvider = null;
|
||||
_authProviderInitialized = false;
|
||||
ApiCredentials = options.ApiCredentials;
|
||||
}
|
||||
|
||||
base.SetOptions(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.Converters
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using CryptoExchange.Net.Objects;
|
||||
using System.IO;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// REST message handler
|
||||
/// </summary>
|
||||
public interface IRestMessageHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The `accept` HTTP response header for the request
|
||||
/// </summary>
|
||||
MediaTypeWithQualityHeaderValue AcceptHeader { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a seekable stream is required
|
||||
/// </summary>
|
||||
bool RequiresSeekableStream { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parse the response when the HTTP response status indicated an error
|
||||
/// </summary>
|
||||
ValueTask<Error> ParseErrorResponse(
|
||||
int httpStatusCode,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream);
|
||||
|
||||
/// <summary>
|
||||
/// Parse the response when the HTTP response status indicated a rate limit error
|
||||
/// </summary>
|
||||
ValueTask<ServerRateLimitError> ParseErrorRateLimitResponse(
|
||||
int httpStatusCode,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the response is an error response; if so return the error.<br />
|
||||
/// Note that if the API returns a standard result wrapper, something like this:
|
||||
/// <code>{ "code": 400, "msg": "error", "data": {} }</code>
|
||||
/// then the `CheckDeserializedResponse` method should be used for checking the result
|
||||
/// </summary>
|
||||
ValueTask<Error?> CheckForErrorResponse(
|
||||
RequestDefinition request,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize the response stream
|
||||
/// </summary>
|
||||
ValueTask<(T? Result, Error? Error)> TryDeserializeAsync<T>(
|
||||
Stream responseStream,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the resulting T object indicates an error or not
|
||||
/// </summary>
|
||||
Error? CheckDeserializedResponse<T>(HttpResponseHeaders responseHeaders, T result);
|
||||
}
|
||||
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// WebSocket message handler
|
||||
/// </summary>
|
||||
public interface ISocketMessageHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Get an identifier for the message which can be used to determine the type of the message
|
||||
/// </summary>
|
||||
string? GetTypeIdentifier(ReadOnlySpan<byte> data, WebSocketMessageType? webSocketMessageType);
|
||||
|
||||
/// <summary>
|
||||
/// Get optional topic filter, for example a symbol name
|
||||
/// </summary>
|
||||
string? GetTopicFilter(object deserializedObject);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize to the provided type
|
||||
/// </summary>
|
||||
object Deserialize(ReadOnlySpan<byte> data, Type type);
|
||||
}
|
||||
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// Message type definition
|
||||
/// </summary>
|
||||
public class MessageTypeDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to immediately select the definition when it is matched. Can only be used when the evaluator has a single unique field to look for
|
||||
/// </summary>
|
||||
public bool ForceIfFound { get; set; }
|
||||
/// <summary>
|
||||
/// The fields a message needs to contain for this definition
|
||||
/// </summary>
|
||||
public MessageFieldReference[] Fields { get; set; } = [];
|
||||
/// <summary>
|
||||
/// The callback for getting the identifier string
|
||||
/// </summary>
|
||||
public Func<SearchResult, string>? TypeIdentifierCallback { get; set; }
|
||||
/// <summary>
|
||||
/// The static identifier string to return when this evaluator is matched
|
||||
/// </summary>
|
||||
public string? StaticIdentifier { get; set; }
|
||||
|
||||
internal string? GetMessageType(SearchResult result)
|
||||
{
|
||||
if (StaticIdentifier != null)
|
||||
return StaticIdentifier;
|
||||
|
||||
return TypeIdentifierCallback!(result);
|
||||
}
|
||||
|
||||
internal bool Satisfied(SearchResult result)
|
||||
{
|
||||
foreach(var field in Fields)
|
||||
{
|
||||
if (!result.Contains(field))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
internal class MessageEvalutorFieldReference
|
||||
{
|
||||
public bool SkipReading { get; set; }
|
||||
public bool OverlappingField { get; set; }
|
||||
public MessageFieldReference Field { get; set; }
|
||||
public MessageTypeDefinition? ForceEvaluator { get; set; }
|
||||
|
||||
public MessageEvalutorFieldReference(MessageFieldReference field)
|
||||
{
|
||||
Field = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference to a message field
|
||||
/// </summary>
|
||||
public abstract class MessageFieldReference
|
||||
{
|
||||
/// <summary>
|
||||
/// The name for this search field
|
||||
/// </summary>
|
||||
public string SearchName { get; set; }
|
||||
/// <summary>
|
||||
/// The depth at which to look for this field
|
||||
/// </summary>
|
||||
public int Depth { get; set; } = 1;
|
||||
/// <summary>
|
||||
/// Callback to check if the field value matches an expected constraint
|
||||
/// </summary>
|
||||
public Func<string?, bool>? Constraint { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the value is one of the string values in the set
|
||||
/// </summary>
|
||||
public MessageFieldReference WithFilterConstraint(HashSet<string?> set)
|
||||
{
|
||||
Constraint = set.Contains;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the value is equal to a string
|
||||
/// </summary>
|
||||
public MessageFieldReference WithEqualConstraint(string compare)
|
||||
{
|
||||
Constraint = x => x != null && x.Equals(compare, StringComparison.Ordinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the value is not equal to a string
|
||||
/// </summary>
|
||||
public MessageFieldReference WithNotEqualConstraint(string compare)
|
||||
{
|
||||
Constraint = x => x == null || !x.Equals(compare, StringComparison.Ordinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the value is not null
|
||||
/// </summary>
|
||||
public MessageFieldReference WithNotNullConstraint()
|
||||
{
|
||||
Constraint = x => x != null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the value starts with a certain string
|
||||
/// </summary>
|
||||
public MessageFieldReference WithStartsWithConstraint(string start)
|
||||
{
|
||||
Constraint = x => x != null && x.StartsWith(start, StringComparison.Ordinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the value starts with a certain string
|
||||
/// </summary>
|
||||
public MessageFieldReference WithStartsWithConstraints(params string[] startValues)
|
||||
{
|
||||
Constraint = x =>
|
||||
{
|
||||
if (x == null)
|
||||
return false;
|
||||
|
||||
foreach (var item in startValues)
|
||||
{
|
||||
if (x!.StartsWith(item, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the value starts with a certain string
|
||||
/// </summary>
|
||||
public MessageFieldReference WithCustomConstraint(Func<string?, bool> constraint)
|
||||
{
|
||||
Constraint = constraint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public MessageFieldReference(string searchName)
|
||||
{
|
||||
SearchName = searchName;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a property message field
|
||||
/// </summary>
|
||||
public class PropertyFieldReference : MessageFieldReference
|
||||
{
|
||||
/// <summary>
|
||||
/// The property name in the JSON
|
||||
/// </summary>
|
||||
public byte[] PropertyName { get; set; }
|
||||
/// <summary>
|
||||
/// Whether the property value is array values
|
||||
/// </summary>
|
||||
public bool ArrayValues { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public PropertyFieldReference(string propertyName) : base(propertyName)
|
||||
{
|
||||
PropertyName = Encoding.UTF8.GetBytes(propertyName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an array message field
|
||||
/// </summary>
|
||||
public class ArrayFieldReference : MessageFieldReference
|
||||
{
|
||||
/// <summary>
|
||||
/// The index in the array
|
||||
/// </summary>
|
||||
public int ArrayIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public ArrayFieldReference(string searchName, int depth, int index) : base(searchName)
|
||||
{
|
||||
Depth = depth;
|
||||
ArrayIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// The results of a search for fields in a JSON message
|
||||
/// </summary>
|
||||
public class SearchResult
|
||||
{
|
||||
private List<SearchResultItem> _items = new List<SearchResultItem>();
|
||||
|
||||
/// <summary>
|
||||
/// Get the value of a field
|
||||
/// </summary>
|
||||
public string? FieldValue(string searchName)
|
||||
{
|
||||
foreach (var item in _items)
|
||||
{
|
||||
if (item.Field.SearchName.Equals(searchName, StringComparison.Ordinal))
|
||||
return item.Value;
|
||||
}
|
||||
|
||||
throw new Exception($"No field value found for {searchName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of found search field values
|
||||
/// </summary>
|
||||
public int Count => _items.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Clear the search result
|
||||
/// </summary>
|
||||
public void Clear() => _items.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the value for a specific field was found
|
||||
/// </summary>
|
||||
public bool Contains(MessageFieldReference field)
|
||||
{
|
||||
foreach (var item in _items)
|
||||
{
|
||||
if (item.Field == field)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a value to the result
|
||||
/// </summary>
|
||||
public void Write(MessageFieldReference field, string? value) => _items.Add(new SearchResultItem
|
||||
{
|
||||
Field = field,
|
||||
Value = value
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing.DynamicConverters
|
||||
{
|
||||
/// <summary>
|
||||
/// Search result value
|
||||
/// </summary>
|
||||
public struct SearchResultItem
|
||||
{
|
||||
/// <summary>
|
||||
/// The field the values is for
|
||||
/// </summary>
|
||||
public MessageFieldReference Field { get; set; }
|
||||
/// <summary>
|
||||
/// The value of the field
|
||||
/// </summary>
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Node accessor
|
||||
/// </summary>
|
||||
public readonly struct NodeAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Index
|
||||
/// </summary>
|
||||
public int? Index { get; }
|
||||
/// <summary>
|
||||
/// Property name
|
||||
/// </summary>
|
||||
public string? Property { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Type (0 = int, 1 = string, 2 = prop name)
|
||||
/// </summary>
|
||||
public int Type { get; }
|
||||
|
||||
private NodeAccessor(int? index, string? property, int type)
|
||||
{
|
||||
Index = index;
|
||||
Property = property;
|
||||
Type = type;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an int node accessor
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static NodeAccessor Int(int value) { return new NodeAccessor(value, null, 0); }
|
||||
|
||||
/// <summary>
|
||||
/// Create a string node accessor
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public static NodeAccessor String(string value) { return new NodeAccessor(null, value, 1); }
|
||||
|
||||
/// <summary>
|
||||
/// Create a property name node accessor
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static NodeAccessor PropertyName() { return new NodeAccessor(null, null, 2); }
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Message access definition
|
||||
/// </summary>
|
||||
public readonly struct MessagePath : IEnumerable<NodeAccessor>
|
||||
{
|
||||
private readonly List<NodeAccessor> _path;
|
||||
|
||||
internal void Add(NodeAccessor node)
|
||||
{
|
||||
_path.Add(node);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
public MessagePath()
|
||||
{
|
||||
_path = new List<NodeAccessor>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new message path
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static MessagePath Get()
|
||||
{
|
||||
return new MessagePath();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IEnumerable implementation
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public IEnumerator<NodeAccessor> GetEnumerator()
|
||||
{
|
||||
for (var i = 0; i < _path.Count; i++)
|
||||
yield return _path[i];
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Message path extension methods
|
||||
/// </summary>
|
||||
public static class MessagePathExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a string node accessor
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="propName"></param>
|
||||
/// <returns></returns>
|
||||
public static MessagePath Property(this MessagePath path, string propName)
|
||||
{
|
||||
path.Add(NodeAccessor.String(propName));
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a property name node accessor
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <returns></returns>
|
||||
public static MessagePath PropertyName(this MessagePath path)
|
||||
{
|
||||
path.Add(NodeAccessor.PropertyName());
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a int node accessor
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="index"></param>
|
||||
/// <returns></returns>
|
||||
public static MessagePath Index(this MessagePath path, int index)
|
||||
{
|
||||
path.Add(NodeAccessor.Int(index));
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace CryptoExchange.Net.Converters.MessageParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Message node type
|
||||
/// </summary>
|
||||
public enum NodeType
|
||||
{
|
||||
/// <summary>
|
||||
/// Array node
|
||||
/// </summary>
|
||||
Array,
|
||||
/// <summary>
|
||||
/// Object node
|
||||
/// </summary>
|
||||
Object,
|
||||
/// <summary>
|
||||
/// Value node
|
||||
/// </summary>
|
||||
Value
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using CryptoExchange.Net.Exceptions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json;
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
{
|
||||
@@ -23,8 +21,8 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
public class ArrayConverter<T> : JsonConverter<T> where T : new()
|
||||
#endif
|
||||
{
|
||||
private static readonly Lazy<List<ArrayPropertyInfo>> _typePropertyInfo = new Lazy<List<ArrayPropertyInfo>>(CacheTypeAttributes, LazyThreadSafetyMode.PublicationOnly);
|
||||
|
||||
private static SortedDictionary<int, List<ArrayPropertyInfo>>? _typePropertyInfo;
|
||||
|
||||
/// <inheritdoc />
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
@@ -38,54 +36,59 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
return;
|
||||
}
|
||||
|
||||
if (_typePropertyInfo == null)
|
||||
_typePropertyInfo = CacheTypeAttributes();
|
||||
|
||||
writer.WriteStartArray();
|
||||
|
||||
var ordered = _typePropertyInfo.Value.Where(x => x.ArrayProperty != null).OrderBy(p => p.ArrayProperty.Index);
|
||||
var last = -1;
|
||||
foreach (var prop in ordered)
|
||||
foreach (var indexProps in _typePropertyInfo)
|
||||
{
|
||||
if (prop.ArrayProperty.Index == last)
|
||||
continue;
|
||||
|
||||
while (prop.ArrayProperty.Index != last + 1)
|
||||
foreach (var prop in indexProps.Value)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
last += 1;
|
||||
}
|
||||
if (prop.ArrayProperty.Index == last)
|
||||
// Don't write the same index twice
|
||||
continue;
|
||||
|
||||
last = prop.ArrayProperty.Index;
|
||||
|
||||
var objValue = prop.PropertyInfo.GetValue(value);
|
||||
if (objValue == null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonSerializerOptions? typeOptions = null;
|
||||
if (prop.JsonConverter != null)
|
||||
{
|
||||
typeOptions = new JsonSerializerOptions
|
||||
while (prop.ArrayProperty.Index != last + 1)
|
||||
{
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
TypeInfoResolver = options.TypeInfoResolver,
|
||||
};
|
||||
typeOptions.Converters.Add(prop.JsonConverter);
|
||||
}
|
||||
writer.WriteNullValue();
|
||||
last += 1;
|
||||
}
|
||||
|
||||
if (prop.JsonConverter == null && IsSimple(prop.PropertyInfo.PropertyType))
|
||||
{
|
||||
if (prop.TargetType == typeof(string))
|
||||
writer.WriteStringValue(Convert.ToString(objValue, CultureInfo.InvariantCulture));
|
||||
else if (prop.TargetType == typeof(bool))
|
||||
writer.WriteBooleanValue((bool)objValue);
|
||||
last = prop.ArrayProperty.Index;
|
||||
|
||||
var objValue = prop.PropertyInfo.GetValue(value);
|
||||
if (objValue == null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonSerializerOptions? typeOptions = null;
|
||||
if (prop.JsonConverter != null)
|
||||
{
|
||||
typeOptions = new JsonSerializerOptions
|
||||
{
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
TypeInfoResolver = options.TypeInfoResolver,
|
||||
};
|
||||
typeOptions.Converters.Add(prop.JsonConverter);
|
||||
}
|
||||
|
||||
if (prop.JsonConverter == null && IsSimple(prop.PropertyInfo.PropertyType))
|
||||
{
|
||||
if (prop.TargetType == typeof(string))
|
||||
writer.WriteStringValue(Convert.ToString(objValue, CultureInfo.InvariantCulture));
|
||||
else if (prop.TargetType == typeof(bool))
|
||||
writer.WriteBooleanValue((bool)objValue);
|
||||
else
|
||||
writer.WriteRawValue(Convert.ToString(objValue, CultureInfo.InvariantCulture)!);
|
||||
}
|
||||
else
|
||||
writer.WriteRawValue(Convert.ToString(objValue, CultureInfo.InvariantCulture)!);
|
||||
}
|
||||
else
|
||||
{
|
||||
JsonSerializer.Serialize(writer, objValue, typeOptions ?? options);
|
||||
{
|
||||
JsonSerializer.Serialize(writer, objValue, typeOptions ?? options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +115,11 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
#endif
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
throw new Exception("Not an array");
|
||||
throw new CeDeserializationException("Not an array");
|
||||
|
||||
|
||||
if (_typePropertyInfo == null)
|
||||
_typePropertyInfo = CacheTypeAttributes();
|
||||
|
||||
int index = 0;
|
||||
while (reader.Read())
|
||||
@@ -120,8 +127,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
if (reader.TokenType == JsonTokenType.EndArray)
|
||||
break;
|
||||
|
||||
var indexAttributes = _typePropertyInfo.Value.Where(a => a.ArrayProperty.Index == index);
|
||||
if (!indexAttributes.Any())
|
||||
if(!_typePropertyInfo.TryGetValue(index, out var indexAttributes))
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
@@ -161,7 +167,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => reader.GetDecimal(),
|
||||
JsonTokenType.StartObject => JsonSerializer.Deserialize(ref reader, attribute.TargetType, options),
|
||||
_ => throw new NotImplementedException($"Array deserialization of type {reader.TokenType} not supported"),
|
||||
_ => throw new CeDeserializationException($"Array deserialization of type {reader.TokenType} not supported"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,12 +199,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
private static List<ArrayPropertyInfo> CacheTypeAttributes()
|
||||
private static SortedDictionary<int, List<ArrayPropertyInfo>> CacheTypeAttributes()
|
||||
#else
|
||||
private static List<ArrayPropertyInfo> CacheTypeAttributes()
|
||||
private static SortedDictionary<int, List<ArrayPropertyInfo>> CacheTypeAttributes()
|
||||
#endif
|
||||
{
|
||||
var attributes = new List<ArrayPropertyInfo>();
|
||||
var result = new SortedDictionary<int, List<ArrayPropertyInfo>>();
|
||||
var properties = typeof(T).GetProperties();
|
||||
foreach (var property in properties)
|
||||
{
|
||||
@@ -208,7 +214,13 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
|
||||
var targetType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
|
||||
var converterType = property.GetCustomAttribute<JsonConverterAttribute>()?.ConverterType ?? targetType.GetCustomAttribute<JsonConverterAttribute>()?.ConverterType;
|
||||
attributes.Add(new ArrayPropertyInfo
|
||||
if (!result.TryGetValue(att.Index, out var indexList))
|
||||
{
|
||||
indexList = new List<ArrayPropertyInfo>();
|
||||
result[att.Index] = indexList;
|
||||
}
|
||||
|
||||
indexList.Add(new ArrayPropertyInfo
|
||||
{
|
||||
ArrayProperty = att,
|
||||
PropertyInfo = property,
|
||||
@@ -218,7 +230,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
});
|
||||
}
|
||||
|
||||
return attributes;
|
||||
return result;
|
||||
}
|
||||
|
||||
private class ArrayPropertyInfo
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -20,58 +20,15 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
/// <inheritdoc />
|
||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
return typeToConvert == typeof(bool) ? new BoolConverterInner<bool>() : new BoolConverterInner<bool?>();
|
||||
return typeToConvert == typeof(bool) ? new BoolConverterInner() : new BoolConverterInnerNullable();
|
||||
}
|
||||
|
||||
private class BoolConverterInner<T> : JsonConverter<T>
|
||||
private class BoolConverterInnerNullable : JsonConverter<bool?>
|
||||
{
|
||||
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> (T)((object?)ReadBool(ref reader, typeToConvert, options) ?? default(T))!;
|
||||
public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> ReadBool(ref reader, typeToConvert, options);
|
||||
|
||||
public bool? ReadBool(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.True)
|
||||
return true;
|
||||
|
||||
if (reader.TokenType == JsonTokenType.False)
|
||||
return false;
|
||||
|
||||
var value = reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => reader.GetInt16().ToString(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
value = value?.ToLowerInvariant().Trim();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (typeToConvert == typeof(bool))
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Received null bool value, but property type is not a nullable bool");
|
||||
return default;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case "true":
|
||||
case "yes":
|
||||
case "y":
|
||||
case "1":
|
||||
case "on":
|
||||
return true;
|
||||
case "false":
|
||||
case "no":
|
||||
case "n":
|
||||
case "0":
|
||||
case "off":
|
||||
case "-1":
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new SerializationException($"Can't convert bool value {value}");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is bool boolVal)
|
||||
writer.WriteBooleanValue(boolVal);
|
||||
@@ -80,5 +37,59 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
}
|
||||
}
|
||||
|
||||
private class BoolConverterInner : JsonConverter<bool>
|
||||
{
|
||||
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> ReadBool(ref reader, typeToConvert, options) ?? false;
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteBooleanValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool? ReadBool(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.True)
|
||||
return true;
|
||||
|
||||
if (reader.TokenType == JsonTokenType.False)
|
||||
return false;
|
||||
|
||||
var value = reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => reader.GetInt16().ToString(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
value = value?.ToLowerInvariant().Trim();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (typeToConvert == typeof(bool))
|
||||
LibraryHelpers.StaticLogger?.LogWarning("Received null or empty bool value, but property type is not a nullable bool. Resolver: {Resolver}", options.TypeInfoResolver?.GetType()?.Name);
|
||||
return default;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case "true":
|
||||
case "yes":
|
||||
case "y":
|
||||
case "1":
|
||||
case "on":
|
||||
return true;
|
||||
case "false":
|
||||
case "no":
|
||||
case "n":
|
||||
case "0":
|
||||
case "off":
|
||||
case "-1":
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new SerializationException($"Can't convert bool value {value}");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
{
|
||||
/// <summary>
|
||||
/// Converter for comma separated string values
|
||||
/// </summary>
|
||||
public class CommaSplitStringConverter : JsonConverter<string[]>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var str = reader.GetString();
|
||||
if (string.IsNullOrEmpty(str))
|
||||
return [];
|
||||
|
||||
return str!.Split(',').ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, string[] value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(string.Join(",", value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
@@ -14,8 +14,8 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
{
|
||||
private static readonly DateTime _epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private const long _ticksPerSecond = TimeSpan.TicksPerMillisecond * 1000;
|
||||
private const double _ticksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000d;
|
||||
private const double _ticksPerNanosecond = TimeSpan.TicksPerMillisecond / 1000d / 1000;
|
||||
private const decimal _ticksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000m;
|
||||
private const decimal _ticksPerNanosecond = TimeSpan.TicksPerMillisecond / 1000m / 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
@@ -26,92 +26,107 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
/// <inheritdoc />
|
||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
return typeToConvert == typeof(DateTime) ? new DateTimeConverterInner<DateTime>() : new DateTimeConverterInner<DateTime?>();
|
||||
return typeToConvert == typeof(DateTime) ? new DateTimeConverterInner() : new NullableDateTimeConverterInner();
|
||||
}
|
||||
|
||||
private class DateTimeConverterInner<T> : JsonConverter<T>
|
||||
private class NullableDateTimeConverterInner : JsonConverter<DateTime?>
|
||||
{
|
||||
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> (T)((object?)ReadDateTime(ref reader, typeToConvert, options) ?? default(T))!;
|
||||
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> ReadDateTime(ref reader, typeToConvert, options);
|
||||
|
||||
private DateTime? ReadDateTime(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
if (typeToConvert == typeof(DateTime))
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | DateTime value of null, but property is not nullable");
|
||||
return default;
|
||||
}
|
||||
|
||||
if (reader.TokenType is JsonTokenType.Number)
|
||||
{
|
||||
var longValue = reader.GetDouble();
|
||||
if (longValue == 0 || longValue < 0)
|
||||
return default;
|
||||
|
||||
return ParseFromDouble(longValue);
|
||||
}
|
||||
else if (reader.TokenType is JsonTokenType.String)
|
||||
{
|
||||
var stringValue = reader.GetString();
|
||||
if (string.IsNullOrWhiteSpace(stringValue)
|
||||
|| stringValue == "-1"
|
||||
|| stringValue == "0001-01-01T00:00:00Z"
|
||||
|| double.TryParse(stringValue, out var doubleVal) && doubleVal == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return ParseFromString(stringValue!);
|
||||
}
|
||||
else
|
||||
{
|
||||
return reader.GetDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.Value == default)
|
||||
writer.WriteStringValue(default(DateTime));
|
||||
else
|
||||
writer.WriteNumberValue((long)Math.Round((value.Value - new DateTime(1970, 1, 1)).TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
|
||||
private class DateTimeConverterInner : JsonConverter<DateTime>
|
||||
{
|
||||
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> ReadDateTime(ref reader, typeToConvert, options) ?? default;
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||
{
|
||||
var dtValue = value;
|
||||
if (dtValue == default)
|
||||
writer.WriteStringValue(default(DateTime));
|
||||
else
|
||||
writer.WriteNumberValue((long)Math.Round((dtValue - new DateTime(1970, 1, 1)).TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime? ReadDateTime(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
if (typeToConvert == typeof(DateTime))
|
||||
LibraryHelpers.StaticLogger?.LogWarning("DateTime value of null, but property is not nullable. Resolver: {Resolver}", options.TypeInfoResolver?.GetType()?.Name);
|
||||
return default;
|
||||
}
|
||||
|
||||
if (reader.TokenType is JsonTokenType.Number)
|
||||
{
|
||||
var decValue = reader.GetDecimal();
|
||||
if (decValue == 0 || decValue < 0)
|
||||
return default;
|
||||
|
||||
return ParseFromDecimal(decValue);
|
||||
}
|
||||
else if (reader.TokenType is JsonTokenType.String)
|
||||
{
|
||||
var stringValue = reader.GetString();
|
||||
if (string.IsNullOrWhiteSpace(stringValue)
|
||||
|| stringValue!.Equals("-1", StringComparison.Ordinal)
|
||||
|| stringValue!.Equals("0001-01-01T00:00:00Z", StringComparison.OrdinalIgnoreCase)
|
||||
|| decimal.TryParse(stringValue, out var decVal) && decVal == 0)
|
||||
{
|
||||
var dtValue = (DateTime)(object)value;
|
||||
if (dtValue == default)
|
||||
writer.WriteStringValue(default(DateTime));
|
||||
else
|
||||
writer.WriteNumberValue((long)Math.Round((dtValue - new DateTime(1970, 1, 1)).TotalMilliseconds));
|
||||
return default;
|
||||
}
|
||||
|
||||
return ParseFromString(stringValue!, options.TypeInfoResolver?.GetType()?.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
return reader.GetDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a long value to datetime
|
||||
/// Parse a double value to datetime
|
||||
/// </summary>
|
||||
/// <param name="longValue"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ParseFromDouble(double longValue)
|
||||
{
|
||||
if (longValue < 19999999999)
|
||||
return ConvertFromSeconds(longValue);
|
||||
if (longValue < 19999999999999)
|
||||
return ConvertFromMilliseconds(longValue);
|
||||
if (longValue < 19999999999999999)
|
||||
return ConvertFromMicroseconds(longValue);
|
||||
public static DateTime ParseFromDouble(double value)
|
||||
=> ParseFromDecimal((decimal)value);
|
||||
|
||||
return ConvertFromNanoseconds(longValue);
|
||||
/// <summary>
|
||||
/// Parse a decimal value to datetime
|
||||
/// </summary>
|
||||
public static DateTime ParseFromDecimal(decimal value)
|
||||
{
|
||||
if (value < 19999999999)
|
||||
return ConvertFromSeconds(value);
|
||||
if (value < 19999999999999)
|
||||
return ConvertFromMilliseconds(value);
|
||||
if (value < 19999999999999999)
|
||||
return ConvertFromMicroseconds(value);
|
||||
|
||||
return ConvertFromNanoseconds(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a string value to datetime
|
||||
/// </summary>
|
||||
/// <param name="stringValue"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ParseFromString(string stringValue)
|
||||
public static DateTime ParseFromString(string stringValue, string? resolverName)
|
||||
{
|
||||
if (stringValue!.Length == 12 && stringValue.StartsWith("202"))
|
||||
if (stringValue!.Length == 12 && stringValue.StartsWith("202", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Parse 202303261200 format
|
||||
if (!int.TryParse(stringValue.Substring(0, 4), out var year)
|
||||
@@ -120,7 +135,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
|| !int.TryParse(stringValue.Substring(8, 2), out var hour)
|
||||
|| !int.TryParse(stringValue.Substring(10, 2), out var minute))
|
||||
{
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
|
||||
LibraryHelpers.StaticLogger?.LogWarning("Unknown DateTime format: {Value}. Resolver: {Resolver}", stringValue, resolverName);
|
||||
return default;
|
||||
}
|
||||
return new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc);
|
||||
@@ -133,7 +148,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
|| !int.TryParse(stringValue.Substring(4, 2), out var month)
|
||||
|| !int.TryParse(stringValue.Substring(6, 2), out var day))
|
||||
{
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
|
||||
LibraryHelpers.StaticLogger?.LogWarning("Unknown DateTime format: {Value}. Resolver: {Resolver}", stringValue, resolverName);
|
||||
return default;
|
||||
}
|
||||
return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc);
|
||||
@@ -146,25 +161,25 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
|| !int.TryParse(stringValue.Substring(2, 2), out var month)
|
||||
|| !int.TryParse(stringValue.Substring(4, 2), out var day))
|
||||
{
|
||||
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
|
||||
LibraryHelpers.StaticLogger?.LogWarning("Unknown DateTime format: {Value}. Resolver: {Resolver}", stringValue, resolverName);
|
||||
return default;
|
||||
}
|
||||
return new DateTime(year + 2000, month, day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
|
||||
if (decimal.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var decimalValue))
|
||||
{
|
||||
// Parse 1637745563.000 format
|
||||
if (doubleValue <= 0)
|
||||
if (decimalValue <= 0)
|
||||
return default;
|
||||
if (doubleValue < 19999999999)
|
||||
return ConvertFromSeconds(doubleValue);
|
||||
if (doubleValue < 19999999999999)
|
||||
return ConvertFromMilliseconds((long)doubleValue);
|
||||
if (doubleValue < 19999999999999999)
|
||||
return ConvertFromMicroseconds((long)doubleValue);
|
||||
if (decimalValue < 19999999999)
|
||||
return ConvertFromSeconds(decimalValue);
|
||||
if (decimalValue < 19999999999999)
|
||||
return ConvertFromMilliseconds(decimalValue);
|
||||
if (decimalValue < 19999999999999999)
|
||||
return ConvertFromMicroseconds(decimalValue);
|
||||
|
||||
return ConvertFromNanoseconds((long)doubleValue);
|
||||
return ConvertFromNanoseconds(decimalValue);
|
||||
}
|
||||
|
||||
if (stringValue.Length == 10)
|
||||
@@ -175,7 +190,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
|| !int.TryParse(values[1], out var month)
|
||||
|| !int.TryParse(values[2], out var day))
|
||||
{
|
||||
Trace.WriteLine("{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Unknown DateTime format: " + stringValue);
|
||||
LibraryHelpers.StaticLogger?.LogWarning("Unknown DateTime format: {Value}. Resolver: {Resolver}", stringValue, resolverName);
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -188,54 +203,70 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
/// <summary>
|
||||
/// Convert a seconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="seconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromSeconds(double seconds) => _epoch.AddTicks((long)Math.Round(seconds * _ticksPerSecond));
|
||||
/// <summary>
|
||||
/// Convert a milliseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="milliseconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromMilliseconds(double milliseconds) => _epoch.AddTicks((long)Math.Round(milliseconds * TimeSpan.TicksPerMillisecond));
|
||||
/// <summary>
|
||||
/// Convert a microseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="microseconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromMicroseconds(double microseconds) => _epoch.AddTicks((long)Math.Round(microseconds * _ticksPerMicrosecond));
|
||||
public static DateTime ConvertFromSeconds(decimal seconds) => _epoch.AddTicks((long)Math.Round(seconds * _ticksPerSecond));
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
/// <param name="nanoseconds"></param>
|
||||
/// <returns></returns>
|
||||
public static DateTime ConvertFromNanoseconds(double nanoseconds) => _epoch.AddTicks((long)Math.Round(nanoseconds * _ticksPerNanosecond));
|
||||
public static DateTime ConvertFromSeconds(double seconds) => ConvertFromSeconds((decimal)seconds);
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromSeconds(long seconds) => ConvertFromSeconds((decimal)seconds);
|
||||
/// <summary>
|
||||
/// Convert a milliseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromMilliseconds(decimal milliseconds) => _epoch.AddTicks((long)Math.Round(milliseconds * TimeSpan.TicksPerMillisecond));
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromMilliseconds(double milliseconds) => ConvertFromMilliseconds((decimal)milliseconds);
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromMilliseconds(long milliseconds) => ConvertFromMilliseconds((decimal)milliseconds);
|
||||
/// <summary>
|
||||
/// Convert a microseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromMicroseconds(decimal microseconds) => _epoch.AddTicks((long)Math.Round(microseconds * _ticksPerMicrosecond));
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromMicroseconds(double microseconds) => ConvertFromMicroseconds((decimal)microseconds);
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromMicroseconds(long microseconds) => ConvertFromMicroseconds((decimal)microseconds);
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromNanoseconds(decimal nanoseconds) => _epoch.AddTicks((long)Math.Round(nanoseconds * _ticksPerNanosecond));
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromNanoseconds(double nanoseconds) => ConvertFromNanoseconds((decimal)nanoseconds);
|
||||
/// <summary>
|
||||
/// Convert a nanoseconds since epoch (01-01-1970) value to DateTime
|
||||
/// </summary>
|
||||
public static DateTime ConvertFromNanoseconds(long nanoseconds) => ConvertFromNanoseconds((decimal)nanoseconds);
|
||||
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to seconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToSeconds(DateTime? time) => time == null ? null : (long)Math.Round((time.Value - _epoch).TotalSeconds);
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to milliseconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToMilliseconds(DateTime? time) => time == null ? null : (long)Math.Round((time.Value - _epoch).TotalMilliseconds);
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to microseconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToMicroseconds(DateTime? time) => time == null ? null : (long)Math.Round((time.Value - _epoch).Ticks / _ticksPerMicrosecond);
|
||||
/// <summary>
|
||||
/// Convert a DateTime value to nanoseconds since epoch (01-01-1970) value
|
||||
/// </summary>
|
||||
/// <param name="time"></param>
|
||||
/// <returns></returns>
|
||||
[return: NotNullIfNotNull("time")]
|
||||
public static long? ConvertToNanoseconds(DateTime? time) => time == null ? null : (long)Math.Round((time.Value - _epoch).Ticks / _ticksPerNanosecond);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using CryptoExchange.Net.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
#if NET8_0_OR_GREATER
|
||||
using System.Collections.Frozen;
|
||||
#endif
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@@ -64,10 +67,29 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
#endif
|
||||
: JsonConverter<T>, INullableConverterFactory where T : struct, Enum
|
||||
{
|
||||
private static List<KeyValuePair<T, string>>? _mapping = null;
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
private static FrozenDictionary<string, T>? _mappingToEnum = null;
|
||||
private static FrozenDictionary<T, string>? _mappingToString = null;
|
||||
|
||||
private static bool RunOptimistic => true;
|
||||
#else
|
||||
private static Dictionary<string, T>? _mappingToEnum = null;
|
||||
private static Dictionary<T, string>? _mappingToString = null;
|
||||
|
||||
// In NetStandard the `ValueTextEquals` method used is slower than just string comparing
|
||||
// so only bother in newer frameworks
|
||||
private static bool RunOptimistic => false;
|
||||
#endif
|
||||
private NullableEnumConverter? _nullableEnumConverter = null;
|
||||
|
||||
private static Type _enumType = typeof(T);
|
||||
private static T? _undefinedEnumValue;
|
||||
private static bool _hasFlagsAttribute = _enumType.IsDefined(typeof(FlagsAttribute));
|
||||
private static ConcurrentBag<string> _unknownValuesWarned = new ConcurrentBag<string>();
|
||||
private static ConcurrentBag<string> _notOptimalValuesWarned = new ConcurrentBag<string>();
|
||||
|
||||
private const int _optimisticValueCountThreshold = 6;
|
||||
|
||||
internal class NullableEnumConverter : JsonConverter<T?>
|
||||
{
|
||||
@@ -79,7 +101,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
}
|
||||
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
return _enumConverter.ReadNullable(ref reader, typeToConvert, options, out var isEmptyString, out var warn);
|
||||
return _enumConverter.ReadNullable(ref reader, typeToConvert, options, out var isEmptyString);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
|
||||
@@ -98,38 +120,51 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
/// <inheritdoc />
|
||||
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var t = ReadNullable(ref reader, typeToConvert, options, out var isEmptyString, out var warn);
|
||||
if (t == null)
|
||||
{
|
||||
if (warn)
|
||||
{
|
||||
if (isEmptyString)
|
||||
{
|
||||
// We received an empty string and have no mapping for it, and the property isn't nullable
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Received empty string as enum value, but property type is not a nullable enum. EnumType: {typeof(T).Name}. If you think {typeof(T).Name} should be nullable please open an issue on the Github repo");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Received null enum value, but property type is not a nullable enum. EnumType: {typeof(T).Name}. If you think {typeof(T).Name} should be nullable please open an issue on the Github repo");
|
||||
}
|
||||
}
|
||||
|
||||
return new T(); // return default value
|
||||
}
|
||||
else
|
||||
{
|
||||
var t = ReadNullable(ref reader, typeToConvert, options, out var isEmptyStringOrNull);
|
||||
if (t != null)
|
||||
return t.Value;
|
||||
|
||||
if (isEmptyStringOrNull && !_unknownValuesWarned.Contains(null))
|
||||
{
|
||||
// We received an empty string and have no mapping for it, and the property isn't nullable
|
||||
_unknownValuesWarned.Add(null!);
|
||||
LibraryHelpers.StaticLogger?.LogWarning($"Received null or empty enum value, but property type is not a nullable enum. EnumType: {typeof(T).FullName}. If you think {typeof(T).FullName} should be nullable please open an issue on the Github repo");
|
||||
}
|
||||
|
||||
return GetUndefinedEnumValue();
|
||||
}
|
||||
|
||||
private T? ReadNullable(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, out bool isEmptyString, out bool warn)
|
||||
private T GetUndefinedEnumValue()
|
||||
{
|
||||
isEmptyString = false;
|
||||
warn = false;
|
||||
var enumType = typeof(T);
|
||||
if (_mapping == null)
|
||||
_mapping = AddMapping();
|
||||
if (_undefinedEnumValue != null)
|
||||
return _undefinedEnumValue.Value;
|
||||
|
||||
var type = typeof(T);
|
||||
if (!Enum.IsDefined(type, -9))
|
||||
_undefinedEnumValue = (T)Enum.ToObject(type, -9);
|
||||
else if (!Enum.IsDefined(type, -99))
|
||||
_undefinedEnumValue = (T)Enum.ToObject(type, -99);
|
||||
else
|
||||
_undefinedEnumValue = (T)Enum.ToObject(type, -999);
|
||||
|
||||
return (T)_undefinedEnumValue;
|
||||
}
|
||||
|
||||
private T? ReadNullable(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, out bool isEmptyStringOrNull)
|
||||
{
|
||||
isEmptyStringOrNull = false;
|
||||
if (_mappingToEnum == null)
|
||||
CreateMapping();
|
||||
|
||||
bool optimisticCheckDone = false;
|
||||
if (RunOptimistic)
|
||||
{
|
||||
var resultOptimistic = GetValueOptimistic(ref reader, ref optimisticCheckDone);
|
||||
if (resultOptimistic != null)
|
||||
return resultOptimistic.Value;
|
||||
}
|
||||
|
||||
var isNumber = reader.TokenType == JsonTokenType.Number;
|
||||
var stringValue = reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
@@ -140,29 +175,41 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
_ => throw new Exception("Invalid token type for enum deserialization: " + reader.TokenType)
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(stringValue))
|
||||
return null;
|
||||
|
||||
if (!GetValue(enumType, stringValue!, out var result))
|
||||
if (stringValue is null)
|
||||
{
|
||||
isEmptyStringOrNull = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!GetValue(stringValue, optimisticCheckDone, out var result))
|
||||
{
|
||||
// Note: checking this here and before the GetValue seems redundant but it allows enum mapping for empty strings
|
||||
if (string.IsNullOrWhiteSpace(stringValue))
|
||||
{
|
||||
isEmptyString = true;
|
||||
isEmptyStringOrNull = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// We received an enum value but weren't able to parse it.
|
||||
if (!_unknownValuesWarned.Contains(stringValue))
|
||||
{
|
||||
warn = true;
|
||||
_unknownValuesWarned.Add(stringValue!);
|
||||
Trace.WriteLine($"{DateTime.Now:yyyy/MM/dd HH:mm:ss:fff} | Warning | Cannot map enum value. EnumType: {enumType.Name}, Value: {stringValue}, Known values: {string.Join(", ", _mapping.Select(m => m.Value))}. If you think {stringValue} should added please open an issue on the Github repo");
|
||||
LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.Key}: {m.Value}"))}]. If you think {stringValue} should be added please open an issue on the Github repo");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (optimisticCheckDone)
|
||||
{
|
||||
if (!_notOptimalValuesWarned.Contains(stringValue))
|
||||
{
|
||||
_notOptimalValuesWarned.Add(stringValue!);
|
||||
LibraryHelpers.StaticLogger?.LogTrace($"Enum mapping sub-optimal. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.Key}: {m.Value}"))}]");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -173,26 +220,78 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
writer.WriteStringValue(stringValue);
|
||||
}
|
||||
|
||||
private static bool GetValue(Type objectType, string value, out T? result)
|
||||
/// <summary>
|
||||
/// Try to get the enum value based on the string value using the Utf8JsonReader's ValueTextEquals method.
|
||||
/// This is an optimization to avoid string allocations when possible, but can only match case sensitively
|
||||
/// </summary>
|
||||
private static T? GetValueOptimistic(ref Utf8JsonReader reader, ref bool optimisticCheckDone)
|
||||
{
|
||||
if (_mapping != null)
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
{
|
||||
// Check for exact match first, then if not found fallback to a case insensitive match
|
||||
var mapping = _mapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCulture));
|
||||
if (mapping.Equals(default(KeyValuePair<T, string>)))
|
||||
mapping = _mapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase));
|
||||
optimisticCheckDone = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mapping.Equals(default(KeyValuePair<T, string>)))
|
||||
if (_mappingToEnum!.Count >= _optimisticValueCountThreshold)
|
||||
{
|
||||
optimisticCheckDone = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
optimisticCheckDone = true;
|
||||
foreach (var item in _mappingToEnum!)
|
||||
{
|
||||
if (reader.ValueTextEquals(item.Key))
|
||||
return item.Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool GetValue(string value, bool optimisticCheckDone, out T? result)
|
||||
{
|
||||
if (_mappingToEnum == null)
|
||||
throw new InvalidOperationException("Enum mapping not initialized");
|
||||
|
||||
T? mapping = null;
|
||||
// If we tried the optimistic path first we already know its not case match
|
||||
if (!optimisticCheckDone)
|
||||
{
|
||||
// Try match on full equals
|
||||
foreach (var item in _mappingToEnum)
|
||||
{
|
||||
result = mapping.Key;
|
||||
return true;
|
||||
if (item.Key.Equals(value, StringComparison.Ordinal))
|
||||
{
|
||||
mapping = item.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (objectType.IsDefined(typeof(FlagsAttribute)))
|
||||
// If not found, try matching ignoring case
|
||||
if (mapping == null)
|
||||
{
|
||||
foreach (var item in _mappingToEnum)
|
||||
{
|
||||
if (item.Key.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mapping = item.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping != null)
|
||||
{
|
||||
result = mapping;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (_hasFlagsAttribute)
|
||||
{
|
||||
var intValue = int.Parse(value);
|
||||
result = (T)Enum.ToObject(objectType, intValue);
|
||||
result = (T)Enum.ToObject(_enumType, intValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -204,10 +303,27 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
return false;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(value))
|
||||
{
|
||||
// An empty/null value will always fail when parsing, so just return here
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// If no explicit mapping is found try to parse string
|
||||
result = (T)Enum.Parse(objectType, value, true);
|
||||
#if NET8_0_OR_GREATER
|
||||
result = Enum.Parse<T>(value, true);
|
||||
#else
|
||||
result = (T)Enum.Parse(_enumType, value, true);
|
||||
#endif
|
||||
if (!Enum.IsDefined(_enumType, result))
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -217,23 +333,43 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
}
|
||||
}
|
||||
|
||||
private static List<KeyValuePair<T, string>> AddMapping()
|
||||
private static void CreateMapping()
|
||||
{
|
||||
var mapping = new List<KeyValuePair<T, string>>();
|
||||
var enumType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
|
||||
var enumMembers = enumType.GetFields();
|
||||
var mappingStringToEnum = new Dictionary<string, T>();
|
||||
var mappingEnumToString = new Dictionary<T, string>();
|
||||
|
||||
#pragma warning disable IL2080
|
||||
var enumMembers = _enumType.GetFields(BindingFlags.Public | BindingFlags.Static);
|
||||
#pragma warning restore IL2080
|
||||
foreach (var member in enumMembers)
|
||||
{
|
||||
var enumVal = (T)member.GetValue(null)!;
|
||||
var maps = member.GetCustomAttributes(typeof(MapAttribute), false);
|
||||
foreach (MapAttribute attribute in maps)
|
||||
{
|
||||
foreach (var value in attribute.Values)
|
||||
mapping.Add(new KeyValuePair<T, string>((T)Enum.Parse(enumType, member.Name), value));
|
||||
{
|
||||
mappingStringToEnum.Add(value, enumVal);
|
||||
if (!mappingEnumToString.ContainsKey(enumVal))
|
||||
mappingEnumToString.Add(enumVal, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mapping = mapping;
|
||||
return mapping;
|
||||
#if NET8_0_OR_GREATER
|
||||
_mappingToEnum = mappingStringToEnum.ToFrozenDictionary();
|
||||
_mappingToString = mappingEnumToString.ToFrozenDictionary();
|
||||
#else
|
||||
_mappingToEnum = mappingStringToEnum;
|
||||
_mappingToString = mappingEnumToString;
|
||||
#endif
|
||||
}
|
||||
|
||||
// For testing purposes only, allows resetting the static mapping and warnings
|
||||
internal static void Reset()
|
||||
{
|
||||
_undefinedEnumValue = null;
|
||||
_unknownValuesWarned = new ConcurrentBag<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -244,10 +380,10 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
[return: NotNullIfNotNull("enumValue")]
|
||||
public static string? GetString(T? enumValue)
|
||||
{
|
||||
if (_mapping == null)
|
||||
_mapping = AddMapping();
|
||||
if (_mappingToString == null)
|
||||
CreateMapping();
|
||||
|
||||
return enumValue == null ? null : (_mapping.FirstOrDefault(v => v.Key.Equals(enumValue)).Value ?? enumValue.ToString());
|
||||
return enumValue == null ? null : (_mappingToString!.TryGetValue(enumValue.Value, out var str) ? str : enumValue.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -257,21 +393,30 @@ namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
/// <returns></returns>
|
||||
public static T? ParseString(string value)
|
||||
{
|
||||
var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
|
||||
if (_mapping == null)
|
||||
_mapping = AddMapping();
|
||||
if (_mappingToEnum == null)
|
||||
CreateMapping();
|
||||
|
||||
var mapping = _mapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCulture));
|
||||
if (mapping.Equals(default(KeyValuePair<T, string>)))
|
||||
mapping = _mapping.FirstOrDefault(kv => kv.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase));
|
||||
// Try match on full equals
|
||||
foreach(var item in _mappingToEnum!)
|
||||
{
|
||||
if (item.Key.Equals(value, StringComparison.Ordinal))
|
||||
return item.Value;
|
||||
}
|
||||
|
||||
if (!mapping.Equals(default(KeyValuePair<T, string>)))
|
||||
return mapping.Key;
|
||||
// If not found, try matching ignoring case
|
||||
foreach (var item in _mappingToEnum)
|
||||
{
|
||||
if (item.Key.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
return item.Value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// If no explicit mapping is found try to parse string
|
||||
return (T)Enum.Parse(type, value, true);
|
||||
#if NET8_0_OR_GREATER
|
||||
return Enum.Parse<T>(value, true);
|
||||
#else
|
||||
return (T)Enum.Parse(_enumType, value, true);
|
||||
#endif
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.SystemTextJson
|
||||
{
|
||||
/// <summary>
|
||||
/// Bool converter
|
||||
/// </summary>
|
||||
public class IntBoolConverter : JsonConverter<bool>
|
||||
{
|
||||
private readonly int _trueValue;
|
||||
|
||||
/// <summary>
|
||||
/// ctor
|
||||
/// </summary>
|
||||
/// <param name="trueValue">The int value representing the true value</param>
|
||||
public IntBoolConverter(int trueValue)
|
||||
{
|
||||
_trueValue = trueValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.Number)
|
||||
return false;
|
||||
|
||||
return reader.GetDecimal() == _trueValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteNumberValue(_trueValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using CryptoExchange.Net.Objects;
|
||||
using CryptoExchange.Net.Objects.Errors;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON REST message handler
|
||||
/// </summary>
|
||||
public abstract class JsonRestMessageHandler : IRestMessageHandler
|
||||
{
|
||||
private static MediaTypeWithQualityHeaderValue _acceptJsonContent = new MediaTypeWithQualityHeaderValue(Constants.JsonContentHeader);
|
||||
private const int _errorResponseSnippetLimit = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Empty rate limit error
|
||||
/// </summary>
|
||||
protected static readonly ServerRateLimitError _emptyRateLimitError = new ServerRateLimitError();
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool RequiresSeekableStream => false;
|
||||
|
||||
/// <summary>
|
||||
/// The serializer options to use
|
||||
/// </summary>
|
||||
public abstract JsonSerializerOptions Options { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public MediaTypeWithQualityHeaderValue AcceptHeader => _acceptJsonContent;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ValueTask<ServerRateLimitError> ParseErrorRateLimitResponse(
|
||||
int httpStatusCode,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream)
|
||||
{
|
||||
// Handle retry after header
|
||||
var retryAfterHeader = responseHeaders.SingleOrDefault(r => r.Key.Equals("Retry-After", StringComparison.InvariantCultureIgnoreCase));
|
||||
if (retryAfterHeader.Value?.Any() != true)
|
||||
return new ValueTask<ServerRateLimitError>(_emptyRateLimitError);
|
||||
|
||||
var value = retryAfterHeader.Value.First();
|
||||
if (int.TryParse(value, out var seconds))
|
||||
return new ValueTask<ServerRateLimitError>(new ServerRateLimitError() { RetryAfter = DateTime.UtcNow.AddSeconds(seconds) });
|
||||
|
||||
if (DateTime.TryParse(value, out var datetime))
|
||||
return new ValueTask<ServerRateLimitError>(new ServerRateLimitError() { RetryAfter = datetime });
|
||||
|
||||
return new ValueTask<ServerRateLimitError>(_emptyRateLimitError);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract ValueTask<Error> ParseErrorResponse(
|
||||
int httpStatusCode,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ValueTask<Error?> CheckForErrorResponse(
|
||||
RequestDefinition request,
|
||||
HttpResponseHeaders responseHeaders,
|
||||
Stream responseStream) => new ValueTask<Error?>((Error?)null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the response into a JsonDocument object
|
||||
/// </summary>
|
||||
protected virtual async ValueTask<(Error?, JsonDocument?)> GetJsonDocument(Stream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
|
||||
return (null, document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMsg = "Deserialization failed, invalid JSON";
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
var dataSnippet = new char[_errorResponseSnippetLimit];
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
var written = new StreamReader(stream).ReadBlock(dataSnippet, 0, _errorResponseSnippetLimit);
|
||||
var data = new string(dataSnippet, 0, written);
|
||||
errorMsg += $": {data}";
|
||||
if (data.Length == _errorResponseSnippetLimit)
|
||||
errorMsg += " (truncated)";
|
||||
}
|
||||
|
||||
var error = new DeserializeError(errorMsg, ex);
|
||||
return (error, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
public async ValueTask<(T? Result, Error? Error)> TryDeserializeAsync<T>(Stream responseStream, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
|
||||
#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
|
||||
var result = await JsonSerializer.DeserializeAsync<T>(responseStream, Options)!.ConfigureAwait(false)!;
|
||||
#pragma warning restore IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
|
||||
#pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
|
||||
return (result, null);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var info = $"Json deserialization failed: {ex.Message}, Path: {ex.Path}, LineNumber: {ex.LineNumber}, LinePosition: {ex.BytePositionInLine}";
|
||||
return (default, new DeserializeError(info, ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (default, new DeserializeError($"Json deserialization failed: {ex.Message}", ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Error? CheckDeserializedResponse<T>(HttpResponseHeaders responseHeaders, T result) => null;
|
||||
}
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON WebSocket message handler, sequentially read the JSON and looks for specific predefined fields to identify the message
|
||||
/// </summary>
|
||||
public abstract class JsonSocketMessageHandler : ISocketMessageHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The serializer options to use
|
||||
/// </summary>
|
||||
public abstract JsonSerializerOptions Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Message evaluators
|
||||
/// </summary>
|
||||
protected abstract MessageTypeDefinition[] TypeEvaluators { get; }
|
||||
|
||||
private readonly SearchResult _searchResult = new();
|
||||
|
||||
private bool _hasArraySearches;
|
||||
private bool _initialized;
|
||||
private int _maxSearchDepth;
|
||||
private MessageTypeDefinition? _topEvaluator;
|
||||
private List<MessageEvalutorFieldReference>? _searchFields;
|
||||
private Dictionary<Type, Func<object, string?>>? _baseTypeMapping;
|
||||
private Dictionary<Type, Func<object, string?>>? _mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Add a mapping of a specific object of a type to a specific topic
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type to get topic for</typeparam>
|
||||
/// <param name="mapping">The topic retrieve delegate</param>
|
||||
protected void AddTopicMapping<T>(Func<T, string?> mapping)
|
||||
{
|
||||
_mapping ??= new Dictionary<Type, Func<object, string?>>();
|
||||
_mapping.Add(typeof(T), x => mapping((T)x));
|
||||
}
|
||||
|
||||
private void InitializeConverter()
|
||||
{
|
||||
if (_initialized)
|
||||
return;
|
||||
|
||||
_maxSearchDepth = int.MinValue;
|
||||
_searchFields = new List<MessageEvalutorFieldReference>();
|
||||
foreach (var evaluator in TypeEvaluators)
|
||||
{
|
||||
_topEvaluator ??= evaluator;
|
||||
foreach (var field in evaluator.Fields)
|
||||
{
|
||||
var overlapping = _searchFields.Where(otherField =>
|
||||
{
|
||||
if (field is PropertyFieldReference propRef
|
||||
&& otherField.Field is PropertyFieldReference otherPropRef)
|
||||
{
|
||||
return field.Depth == otherPropRef.Depth && propRef.PropertyName.SequenceEqual(otherPropRef.PropertyName);
|
||||
}
|
||||
else if (field is ArrayFieldReference arrayRef
|
||||
&& otherField.Field is ArrayFieldReference otherArrayPropRef)
|
||||
{
|
||||
return field.Depth == otherArrayPropRef.Depth && arrayRef.ArrayIndex == otherArrayPropRef.ArrayIndex;
|
||||
}
|
||||
|
||||
return false;
|
||||
}).ToList();
|
||||
|
||||
if (overlapping.Any())
|
||||
{
|
||||
foreach (var overlap in overlapping)
|
||||
overlap.OverlappingField = true;
|
||||
}
|
||||
|
||||
List<MessageEvalutorFieldReference>? existingSameSearchField = new();
|
||||
if (field is ArrayFieldReference arrayField)
|
||||
{
|
||||
_hasArraySearches = true;
|
||||
existingSameSearchField = _searchFields.Where(x =>
|
||||
x.Field is ArrayFieldReference arrayFieldRef
|
||||
&& arrayFieldRef.ArrayIndex == arrayField.ArrayIndex
|
||||
&& arrayFieldRef.Depth == arrayField.Depth
|
||||
&& arrayFieldRef.Constraint == null && arrayField.Constraint == null).ToList();
|
||||
}
|
||||
else if (field is PropertyFieldReference propField)
|
||||
{
|
||||
existingSameSearchField = _searchFields.Where(x =>
|
||||
x.Field is PropertyFieldReference propFieldRef
|
||||
&& propFieldRef.PropertyName.SequenceEqual(propField.PropertyName)
|
||||
&& propFieldRef.Depth == propField.Depth
|
||||
&& propFieldRef.Constraint == null && propFieldRef.Constraint == null).ToList();
|
||||
}
|
||||
|
||||
foreach(var sameSearchField in existingSameSearchField)
|
||||
{
|
||||
if (sameSearchField.SkipReading == true
|
||||
&& (evaluator.TypeIdentifierCallback != null || field.Constraint != null))
|
||||
{
|
||||
sameSearchField.SkipReading = false;
|
||||
}
|
||||
|
||||
if (evaluator.ForceIfFound)
|
||||
{
|
||||
if (evaluator.Fields.Length > 1 || sameSearchField.ForceEvaluator != null)
|
||||
throw new Exception("Invalid config");
|
||||
|
||||
//sameSearchField.ForceEvaluator = evaluator;
|
||||
}
|
||||
}
|
||||
|
||||
_searchFields.Add(new MessageEvalutorFieldReference(field)
|
||||
{
|
||||
SkipReading = evaluator.TypeIdentifierCallback == null && field.Constraint == null,
|
||||
ForceEvaluator = !existingSameSearchField.Any() ? evaluator.ForceIfFound ? evaluator : null : null,
|
||||
OverlappingField = overlapping.Any()
|
||||
});
|
||||
|
||||
if (field.Depth > _maxSearchDepth)
|
||||
_maxSearchDepth = field.Depth;
|
||||
}
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string? GetTopicFilter(object deserializedObject)
|
||||
{
|
||||
if (_mapping == null)
|
||||
return null;
|
||||
|
||||
// Cache the found type for future
|
||||
var currentType = deserializedObject.GetType();
|
||||
if (_baseTypeMapping != null)
|
||||
{
|
||||
if (_baseTypeMapping.TryGetValue(currentType, out var typeMapping))
|
||||
return typeMapping(deserializedObject);
|
||||
}
|
||||
|
||||
var mappedBase = false;
|
||||
while (currentType != null)
|
||||
{
|
||||
if (_mapping.TryGetValue(currentType, out var mapping))
|
||||
{
|
||||
if (mappedBase)
|
||||
{
|
||||
_baseTypeMapping ??= new Dictionary<Type, Func<object, string?>>();
|
||||
_baseTypeMapping.Add(deserializedObject.GetType(), mapping);
|
||||
}
|
||||
|
||||
return mapping(deserializedObject);
|
||||
}
|
||||
|
||||
mappedBase = true;
|
||||
currentType = currentType.BaseType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return type identifier for non-json messages
|
||||
/// </summary>
|
||||
protected virtual string? GetTypeIdentifierNonJson(ReadOnlySpan<byte> data, WebSocketMessageType? webSocketMessageType)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string? GetTypeIdentifier(ReadOnlySpan<byte> data, WebSocketMessageType? webSocketMessageType)
|
||||
{
|
||||
InitializeConverter();
|
||||
|
||||
int? arrayIndex = null;
|
||||
|
||||
_searchResult.Clear();
|
||||
if (data[0] != 0x5B && data[0] != 0x7B)
|
||||
{
|
||||
// Message doesn't start with `{` or `[`, not valid for processing as json
|
||||
return GetTypeIdentifierNonJson(data, webSocketMessageType);
|
||||
}
|
||||
|
||||
var reader = new Utf8JsonReader(data);
|
||||
while (reader.Read())
|
||||
{
|
||||
if ((reader.TokenType == JsonTokenType.StartArray
|
||||
|| reader.TokenType == JsonTokenType.StartObject)
|
||||
&& reader.CurrentDepth == _maxSearchDepth)
|
||||
{
|
||||
// There is no field we need to search for on a depth deeper than this, skip
|
||||
reader.Skip();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonTokenType.StartArray)
|
||||
arrayIndex = -1;
|
||||
else if (reader.TokenType == JsonTokenType.EndArray)
|
||||
arrayIndex = null;
|
||||
else if (arrayIndex != null)
|
||||
arrayIndex++;
|
||||
|
||||
if (reader.TokenType == JsonTokenType.PropertyName
|
||||
|| arrayIndex != null && _hasArraySearches)
|
||||
{
|
||||
bool written = false;
|
||||
|
||||
string? value = null;
|
||||
byte[]? propName = null;
|
||||
foreach (var field in _searchFields!)
|
||||
{
|
||||
if (field.Field.Depth != reader.CurrentDepth)
|
||||
continue;
|
||||
|
||||
bool readArrayValues = false;
|
||||
if (field.Field is PropertyFieldReference propFieldRef)
|
||||
{
|
||||
if (propName == null)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
continue;
|
||||
|
||||
if (!reader.ValueTextEquals(propFieldRef.PropertyName))
|
||||
continue;
|
||||
|
||||
propName = propFieldRef.PropertyName;
|
||||
readArrayValues = propFieldRef.ArrayValues;
|
||||
reader.Read();
|
||||
}
|
||||
else if (!propFieldRef.PropertyName.SequenceEqual(propName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (field.Field is ArrayFieldReference arrayFieldRef)
|
||||
{
|
||||
if (propName != null)
|
||||
continue;
|
||||
|
||||
if (reader.TokenType == JsonTokenType.PropertyName)
|
||||
continue;
|
||||
|
||||
if (arrayFieldRef.ArrayIndex != arrayIndex)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!field.SkipReading)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
if (readArrayValues)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
// error
|
||||
return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
reader.Read();// Read start array
|
||||
bool first = true;
|
||||
while(reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
if (!first)
|
||||
sb.Append(",");
|
||||
|
||||
first = false;
|
||||
sb.Append(reader.GetString());
|
||||
reader.Read();
|
||||
}
|
||||
|
||||
value = first ? null : sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.Number:
|
||||
value = reader.GetDecimal().ToString();
|
||||
break;
|
||||
case JsonTokenType.String:
|
||||
value = reader.GetString()!;
|
||||
break;
|
||||
case JsonTokenType.True:
|
||||
case JsonTokenType.False:
|
||||
value = reader.GetBoolean().ToString()!;
|
||||
break;
|
||||
case JsonTokenType.Null:
|
||||
value = null;
|
||||
break;
|
||||
case JsonTokenType.StartObject:
|
||||
case JsonTokenType.StartArray:
|
||||
value = null;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field.Field.Constraint != null
|
||||
&& !field.Field.Constraint(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_searchResult.Write(field.Field, value);
|
||||
|
||||
if (field.ForceEvaluator != null)
|
||||
{
|
||||
if (field.ForceEvaluator.StaticIdentifier != null)
|
||||
return field.ForceEvaluator.StaticIdentifier;
|
||||
|
||||
// Force the immediate return upon encountering this field
|
||||
return field.ForceEvaluator.GetMessageType(_searchResult);
|
||||
}
|
||||
|
||||
written = true;
|
||||
if (!field.OverlappingField)
|
||||
break;
|
||||
}
|
||||
|
||||
if (!written)
|
||||
continue;
|
||||
|
||||
if (_topEvaluator!.Satisfied(_searchResult))
|
||||
return _topEvaluator.GetMessageType(_searchResult);
|
||||
|
||||
if (_searchFields.Count == _searchResult.Count)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var evaluator in TypeEvaluators)
|
||||
{
|
||||
if (evaluator.Satisfied(_searchResult))
|
||||
return evaluator.GetMessageType(_searchResult);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
#if NET5_0_OR_GREATER
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3050:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "JsonSerializerOptions provided here has TypeInfoResolver set")]
|
||||
#endif
|
||||
public virtual object Deserialize(ReadOnlySpan<byte> data, Type type)
|
||||
{
|
||||
#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
|
||||
#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
|
||||
return JsonSerializer.Deserialize(data, type, Options)!;
|
||||
#pragma warning restore IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
|
||||
#pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters;
|
||||
using System;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON WebSocket message handler, reads the json data info a JsonDocument after which the data can be inspected to identify the message
|
||||
/// </summary>
|
||||
public abstract class JsonSocketPreloadMessageHandler : ISocketMessageHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The serializer options to use
|
||||
/// </summary>
|
||||
public abstract JsonSerializerOptions Options { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string? GetTypeIdentifier(ReadOnlySpan<byte> data, WebSocketMessageType? webSocketMessageType)
|
||||
{
|
||||
var reader = new Utf8JsonReader(data);
|
||||
var jsonDocument = JsonDocument.ParseValue(ref reader);
|
||||
|
||||
return GetTypeIdentifier(jsonDocument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the message identifier for this document
|
||||
/// </summary>
|
||||
protected abstract string? GetTypeIdentifier(JsonDocument document);
|
||||
|
||||
/// <summary>
|
||||
/// Get optional topic filter, for example a symbol name
|
||||
/// </summary>
|
||||
public virtual string? GetTopicFilter(object deserializedObject) => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual object Deserialize(ReadOnlySpan<byte> data, Type type)
|
||||
{
|
||||
#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
|
||||
#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
|
||||
return JsonSerializer.Deserialize(data, type, Options)!;
|
||||
#pragma warning restore IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
|
||||
#pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the string value for a path, or an emtpy string if not found
|
||||
/// </summary>
|
||||
protected string StringOrEmpty(JsonDocument document, string path)
|
||||
{
|
||||
if (!document.RootElement.TryGetProperty(path, out var element))
|
||||
return string.Empty;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
return element.GetString() ?? string.Empty;
|
||||
else if (element.ValueKind == JsonValueKind.Number)
|
||||
return element.GetDecimal().ToString();
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user