diff --git a/.gitignore b/.gitignore index 62e4e8f..01cd9f4 100644 --- a/.gitignore +++ b/.gitignore @@ -350,3 +350,5 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ +# User-specific configuration +appsettings.json diff --git a/BtcMarkets.sln b/BtcMarkets.sln new file mode 100644 index 0000000..f3008ac --- /dev/null +++ b/BtcMarkets.sln @@ -0,0 +1,30 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BtcMarkets.Client", "src\BtcMarkets.Client\BtcMarkets.Client.csproj", "{A1B2C3D4-E5F6-7890-A1B2-C3D4E5F67890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BtcMarkets.ConsoleSample", "src\BtcMarkets.ConsoleSample\BtcMarkets.ConsoleSample.csproj", "{B2C3D4E5-F6A7-8901-B2C3-D4E5F6A78901}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BtcMarkets.Client.Tests", "tests\BtcMarkets.Client.Tests\BtcMarkets.Client.Tests.csproj", "{C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-A1B2-C3D4E5F67890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-A1B2-C3D4E5F67890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-A1B2-C3D4E5F67890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-A1B2-C3D4E5F67890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-B2C3-D4E5F6A78901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-B2C3-D4E5F6A78901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-B2C3-D4E5F6A78901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-B2C3-D4E5F6A78901}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-C3D4-E5F6A7B89012}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/ApiClient.cs b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/ApiClient.cs deleted file mode 100644 index 0c01972..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/ApiClient.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Text; -using System.Net.Http; -using System.Configuration; -using System.Threading.Tasks; -using System.Security.Cryptography; -using Newtonsoft.Json; -using BtcMarketsApiClient.Sample.Models; - -namespace BtcMarketsApiClient.Sample -{ - public class ApiClient - { - private readonly string _baseUrl; - private readonly string _apiKey; - private readonly string _privateKey; - - public ApiClient(string baseUrl, string apiKey, string privateKey) - { - _baseUrl = baseUrl; - _apiKey = apiKey; - _privateKey = privateKey; - } - - public async Task Get(string path, string queryString) - { - using (var client = new HttpClient()) - { - client.BaseAddress = new Uri(_baseUrl); - GenerateHeaders(client, "GET", null, path); - - var fullPath = !string.IsNullOrEmpty(queryString) ? path + "?" + queryString : path; - - var response = await client.GetAsync(fullPath); - if (!response.IsSuccessStatusCode) - Console.WriteLine("Error: " + response.StatusCode.ToString()); - - var content = await response.Content.ReadAsStringAsync(); - return new ResponseModel - { - Headers = response.Headers, - Content = await response.Content.ReadAsStringAsync() - }; - } - } - - public async Task Post(string path, string queryString, object data) - { - using (var client = new HttpClient()) - { - client.BaseAddress = new Uri(_baseUrl); - var stringifiedData = data != null ? JsonConvert.SerializeObject(data) : null; - GenerateHeaders(client, "POST", stringifiedData, path); - - var fullPath = !string.IsNullOrEmpty(queryString) ? path + "?" + queryString : path; - var content = new StringContent(stringifiedData, Encoding.UTF8, "application/json"); - - var response = await client.PostAsync(fullPath, content); - if (!response.IsSuccessStatusCode) - Console.WriteLine("Error: " + response.StatusCode.ToString()); - - return await response.Content.ReadAsStringAsync(); - } - } - - public async Task Put(string path, string queryString, object data) - { - using (var client = new HttpClient()) - { - client.BaseAddress = new Uri(_baseUrl); - var stringifiedData = data != null ? JsonConvert.SerializeObject(data) : null; - GenerateHeaders(client, "PUT", stringifiedData, path); - - var fullPath = !string.IsNullOrEmpty(queryString) ? path + "?" + queryString : path; - var content = new StringContent(stringifiedData, Encoding.UTF8, "application/json"); - - var response = await client.PutAsync(fullPath, content); - if (!response.IsSuccessStatusCode) - Console.WriteLine("Error: " + response.StatusCode.ToString()); - - return await response.Content.ReadAsStringAsync(); - } - } - - public async Task Delete(string path, string queryString) - { - using (var client = new HttpClient()) - { - client.BaseAddress = new Uri(_baseUrl); - GenerateHeaders(client, "DELETE", null, path); - - var fullPath = !string.IsNullOrEmpty(queryString) ? path + "?" + queryString : path; - - var response = await client.DeleteAsync(fullPath); - if (!response.IsSuccessStatusCode) - Console.WriteLine("Error: " + response.StatusCode.ToString()); - - return await response.Content.ReadAsStringAsync(); - } - } - - - private void GenerateHeaders(HttpClient client, string method, string data, string path) - { - long now = DateTimeOffset.Now.ToUnixTimeMilliseconds(); - var message = method + path + now.ToString(); - if (!string.IsNullOrEmpty(data)) - message += data; - - string signature = SignMessage(message); - client.DefaultRequestHeaders.Add("Accept", "application /json"); - client.DefaultRequestHeaders.Add("Accept-Charset", "UTF-8"); - client.DefaultRequestHeaders.Add("BM-AUTH-APIKEY", _apiKey); - client.DefaultRequestHeaders.Add("BM-AUTH-TIMESTAMP", now.ToString()); - client.DefaultRequestHeaders.Add("BM-AUTH-SIGNATURE", signature); - } - - private string SignMessage(string message) - { - var bytes = Encoding.UTF8.GetBytes(message); - - using (var hash = new HMACSHA512(Convert.FromBase64String(_privateKey))) - { - var hashedInputeBytes = hash.ComputeHash(bytes); - return Convert.ToBase64String(hashedInputeBytes); - } - } - } -} diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/App.config b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/App.config deleted file mode 100644 index 56efbc7..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/App.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/BtcMarketsApiClient.Sample.csproj b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/BtcMarketsApiClient.Sample.csproj deleted file mode 100644 index d0d55e9..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/BtcMarketsApiClient.Sample.csproj +++ /dev/null @@ -1,62 +0,0 @@ - - - - - Debug - AnyCPU - {EA720CDE-AFEC-4098-9255-1B784CAD27D4} - Exe - BtcMarketsApiClient.Sample - BtcMarketsApiClient.Sample - v4.7.2 - 512 - true - true - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - ..\packages\Newtonsoft.Json.12.0.2\lib\net45\Newtonsoft.Json.dll - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Models/NewOrderModel.cs b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Models/NewOrderModel.cs deleted file mode 100644 index 0aca854..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Models/NewOrderModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Newtonsoft.Json; - -namespace BtcMarketsApiClient.Sample.Models -{ - public class NewOrderModel - { - [JsonProperty("marketId")] - public string MarketId { get; set; } - - [JsonProperty("price")] - public string Price { get; set; } - - [JsonProperty("amount")] - public string Amount { get; set; } - - [JsonProperty("type")] - public string Type { get; set; } - - [JsonProperty("side")] - public string Side { get; set; } - - [JsonProperty("triggerPrice")] - public string TriggerPrice { get; set; } - - [JsonProperty("targetAmount")] - public string TargetAmount { get; set; } - - [JsonProperty("timeInForce")] - public string TimeInForce { get; set; } - - [JsonProperty("postOnly")] - public string PostOnly { get; set; } - - [JsonProperty("selfTrade")] - public string SelfTrade { get; set; } - - [JsonProperty("clientOrderId")] - public string ClientOrderId { get; set; } - } -} diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Models/ResponseModel.cs b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Models/ResponseModel.cs deleted file mode 100644 index c2709e9..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Models/ResponseModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Net.Http.Headers; - -namespace BtcMarketsApiClient.Sample.Models -{ - public class ResponseModel - { - public HttpResponseHeaders Headers { get; set; } - public string Content { get; set; } - } -} diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Orders.cs b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Orders.cs deleted file mode 100644 index 1889b43..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Orders.cs +++ /dev/null @@ -1,64 +0,0 @@ -using BtcMarketsApiClient.Sample.Models; -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace BtcMarketsApiClient.Sample -{ - public class Orders - { - private readonly ApiClient _apiClient; - - public Orders(string baseUrl, string apiKey, string privateKey) - { - _apiClient = new ApiClient(baseUrl, apiKey, privateKey); - } - - public async Task GetOpenOrdersAsync() - { - var orders = await _apiClient.Get("/v3/orders", "status=open"); - - Console.WriteLine(orders.Content); - } - - public async Task GetOrdersAsync() - { - var orders = await _apiClient.Get("/v3/orders", "status=all&limit=5"); - - Console.WriteLine(orders.Content); - var hasBefore = orders.Headers.TryGetValues("BM_BEFORE", out IEnumerable befores); - var hasAfter = orders.Headers.TryGetValues("BM-AFTER", out IEnumerable afters); - var queryString = "status=all&limit=5"; - - if (hasBefore) - queryString += $"&before={befores.First()}"; - - if (hasAfter) - queryString += $"&after={afters.First()}"; - - orders = await _apiClient.Get("/v3/orders", queryString); - - Console.WriteLine(orders.Content); - } - - public async Task PlaceNewOrder(NewOrderModel model) - { - var result = await _apiClient.Post("/v3/orders", null, model); - Console.WriteLine(result); - } - - public async Task CancelOrder(string id) - { - var result = await _apiClient.Delete($"/v3/orders/{id}", null); - Console.WriteLine(result); - } - - public async Task CancelAll() - { - var result = await _apiClient.Delete($"/v3/orders", "marketId=BTC-AUD"); - Console.WriteLine(result); - } - } -} diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Program.cs b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Program.cs deleted file mode 100644 index a0501f6..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Program.cs +++ /dev/null @@ -1,44 +0,0 @@ -using BtcMarketsApiClient.Sample.Models; - - -namespace BtcMarketsApiClient.Sample -{ - class Program - { - private const string BaseUrl = "https://api.btcmarkets.net"; - private const string ApiKey = "add api key here"; - private const string PrivateKey = "add private key here"; - static void Main(string[] args) - { - var orders = new Orders(BaseUrl, ApiKey, PrivateKey); - - //Get Orders Sample - //orders.GetOpenOrdersAsync().Wait(); - orders.GetOrdersAsync().Wait(); - - //Place new Order sample - /* - var newOrder = new NewOrderModel - { - MarketId = "BTC-AUD", - Price = "100.12", - Amount = "1.034", - Type = "Limit", - Side = "Bid" - }; - - orders.PlaceNewOrder(newOrder).Wait(); - */ - - //Cancel Order - /* - orders.CancelOrder("1224732").Wait(); - */ - - //Cancel All Orders - /* - orders.CancelAll().Wait(); - */ - } - } -} diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Properties/AssemblyInfo.cs b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Properties/AssemblyInfo.cs deleted file mode 100644 index fad0a7b..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("BtcMarketsApiClient.Sample")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("BtcMarketsApiClient.Sample")] -[assembly: AssemblyCopyright("Copyright © 2019")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("ea720cde-afec-4098-9255-1b784cad27d4")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/packages.config b/BtcMarketsApiClient/BtcMarketsApiClient.Sample/packages.config deleted file mode 100644 index a75532f..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.Sample/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/BtcMarketsApiClient/BtcMarketsApiClient.sln b/BtcMarketsApiClient/BtcMarketsApiClient.sln deleted file mode 100644 index 7d81c78..0000000 --- a/BtcMarketsApiClient/BtcMarketsApiClient.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29102.190 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BtcMarketsApiClient.Sample", "BtcMarketsApiClient.Sample\BtcMarketsApiClient.Sample.csproj", "{EA720CDE-AFEC-4098-9255-1B784CAD27D4}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {EA720CDE-AFEC-4098-9255-1B784CAD27D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EA720CDE-AFEC-4098-9255-1B784CAD27D4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EA720CDE-AFEC-4098-9255-1B784CAD27D4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EA720CDE-AFEC-4098-9255-1B784CAD27D4}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {500B74A5-3694-4BED-974F-046457CAD19D} - EndGlobalSection -EndGlobal diff --git a/README.md b/README.md index 8fcb42d..43f0d3b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,99 @@ -# api-v3-client-dotnet +# BTCMarkets .NET 9 Client -This application is a sample API client in .NET that is only meant to demonstrate API authentication, placing, canceling, and listing orders. +A modern, production-ready .NET 9 Class Library and Sample Application for the BTCMarkets V3 API. +## Project Structure + +This solution (`BtcMarkets.sln`) is organized into the following components: + +* **`src/BtcMarkets.Client`**: The reusable Class Library containing all API logic, models, exceptions, and `ServiceCollection` extensions. +* **`src/BtcMarkets.ConsoleSample`**: A console application demonstrating how to consume the library to place, list, and cancel orders. +* **`tests/BtcMarkets.Client.Tests`**: xUnit test project with unit tests for the core library. + +## Features + +- **.NET 9**: Built for the latest .NET release. +- **Dependency Injection**: Seamless integration via `services.AddBtcMarkets()`. +- **Robust Error Handling**: Typed `BtcMarketsException` with HTTP status codes. +- **Structured Logging**: Full `ILogger` support. +- **Thread Safety**: Uses `IHttpClientFactory` and per-request `HttpRequestMessage` handling. +- **Async/Await**: Non-blocking I/O with `ConfigureAwait(false)` best practices. + +## Getting Started + +### 1. Prerequisites +- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) +- A BTCMarkets Account (API Key & Private Key) + +### 2. Configuration +Open `src/BtcMarkets.ConsoleSample/appsettings.json` and add your keys: +```json +{ + "BtcMarkets": { + "ApiKey": "YOUR_API_KEY", + "PrivateKey": "YOUR_PRIVATE_KEY" + } +} +``` + +### 3. Build the Solution +Run from the root directory: +```bash +dotnet build +``` + +### 4. Run Unit Tests +```bash +dotnet test +``` + +### 5. Run the Demo +```bash +dotnet run --project src/BtcMarkets.ConsoleSample +``` + +## Usage in Your App + +1. **Add Reference**: Add `BtcMarkets.Client` to your project. +2. **Register Services**: In your `Program.cs` or `Startup.cs`: + +```csharp +using BtcMarkets.Client; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddBtcMarkets(options => +{ + options.BaseUrl = "https://api.btcmarkets.net"; + options.ApiKey = builder.Configuration["BtcMarkets:ApiKey"]; + options.PrivateKey = builder.Configuration["BtcMarkets:PrivateKey"]; +}); +``` + +3. **Inject and Use**: + +```csharp +public class MyTradingService +{ + private readonly IBtcMarketsClient _client; + private readonly ILogger _logger; + + public MyTradingService(IBtcMarketsClient client, ILogger logger) + { + _client = client; + _logger = logger; + } + + public async Task PlaceOrderAsync() + { + try + { + await _client.PlaceNewOrderAsync(new NewOrderModel { ... }); + } + catch (BtcMarketsException ex) + { + _logger.LogError("API Error {Code}: {Message}", ex.StatusCode, ex.Message); + } + } +} +``` diff --git a/src/BtcMarkets.Client/BtcMarkets.Client.csproj b/src/BtcMarkets.Client/BtcMarkets.Client.csproj new file mode 100644 index 0000000..0ef0777 --- /dev/null +++ b/src/BtcMarkets.Client/BtcMarkets.Client.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + BtcMarkets.Client + BtcMarkets.Client + + + + + + + + + + diff --git a/src/BtcMarkets.Client/BtcMarketsClient.cs b/src/BtcMarkets.Client/BtcMarketsClient.cs new file mode 100644 index 0000000..dc66568 --- /dev/null +++ b/src/BtcMarkets.Client/BtcMarketsClient.cs @@ -0,0 +1,139 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using BtcMarkets.Client.Exceptions; +using BtcMarkets.Client.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +namespace BtcMarkets.Client; + +public class BtcMarketsClient : IBtcMarketsClient +{ + private readonly HttpClient _httpClient; + private readonly BtcMarketsOptions _options; + private readonly ILogger _logger; + + public BtcMarketsClient(HttpClient httpClient, IOptions options, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + if (string.IsNullOrWhiteSpace(_options.ApiKey) || string.IsNullOrWhiteSpace(_options.PrivateKey)) + { + _logger.LogWarning("BtcMarketsClient initialized with empty API Key or Private Key."); + } + + // Ensure base address is set + if (_httpClient.BaseAddress == null) + { + _httpClient.BaseAddress = new Uri(_options.BaseUrl); + } + } + + public async Task GetOrdersAsync(string status = "all", int limit = 5, string? before = null, string? after = null) + { + var queryString = $"status={status}&limit={limit}"; + if (!string.IsNullOrEmpty(before)) queryString += $"&before={before}"; + if (!string.IsNullOrEmpty(after)) queryString += $"&after={after}"; + + var response = await SendRequestAsync(HttpMethod.Get, "/v3/orders", queryString, null).ConfigureAwait(false); + return new ResponseModel + { + Headers = response.Headers, + Content = await response.Content.ReadAsStringAsync().ConfigureAwait(false) + }; + } + + public async Task PlaceNewOrderAsync(NewOrderModel order) + { + _logger.LogInformation("Placing new order: {MarketId} {Side} {Type} {Amount} @ {Price}", + order.MarketId, order.Side, order.Type, order.Amount, order.Price); + + var response = await SendRequestAsync(HttpMethod.Post, "/v3/orders", null, order).ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return content; + } + + public async Task CancelOrderAsync(string orderId) + { + _logger.LogInformation("Cancelling order {OrderId}", orderId); + + var response = await SendRequestAsync(HttpMethod.Delete, $"/v3/orders/{orderId}", null, null).ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return content; + } + + public async Task CancelAllOrdersAsync(string marketId = "BTC-AUD") + { + _logger.LogInformation("Cancelling all orders for market {MarketId}", marketId); + + var queryString = $"marketId={marketId}"; + var response = await SendRequestAsync(HttpMethod.Delete, "/v3/orders", queryString, null).ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return content; + } + + private async Task SendRequestAsync(HttpMethod method, string path, string? queryString, object? data) + { + var fullPath = !string.IsNullOrEmpty(queryString) ? path + "?" + queryString : path; + string? stringifiedData = data != null ? JsonConvert.SerializeObject(data) : null; + + using var request = new HttpRequestMessage(method, fullPath); + + if (data != null) + { + request.Content = new StringContent(stringifiedData ?? string.Empty, Encoding.UTF8, "application/json"); + } + + AddAuthHeaders(request, method.Method, stringifiedData, path); + + try + { + var response = await _httpClient.SendAsync(request).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + _logger.LogError("API Error {StatusCode}: {Content}", response.StatusCode, errorContent); + throw new BtcMarketsException($"API Error: {errorContent}", (int)response.StatusCode); + } + + return response; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP Request failed"); + throw new BtcMarketsException("HTTP Request failed", ex); + } + } + + private void AddAuthHeaders(HttpRequestMessage request, string method, string? data, string path) + { + long now = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + var message = method + path + now.ToString(); + if (!string.IsNullOrEmpty(data)) + message += data; + + string signature = SignMessage(message); + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Add("Accept-Charset", "UTF-8"); + request.Headers.Add("BM-AUTH-APIKEY", _options.ApiKey); + request.Headers.Add("BM-AUTH-TIMESTAMP", now.ToString()); + request.Headers.Add("BM-AUTH-SIGNATURE", signature); + } + + private string SignMessage(string message) + { + var bytes = Encoding.UTF8.GetBytes(message); + using var hash = new HMACSHA512(Convert.FromBase64String(_options.PrivateKey)); + var hashedInputBytes = hash.ComputeHash(bytes); + return Convert.ToBase64String(hashedInputBytes); + } +} diff --git a/src/BtcMarkets.Client/BtcMarketsOptions.cs b/src/BtcMarkets.Client/BtcMarketsOptions.cs new file mode 100644 index 0000000..5dc6d68 --- /dev/null +++ b/src/BtcMarkets.Client/BtcMarketsOptions.cs @@ -0,0 +1,8 @@ +namespace BtcMarkets.Client; + +public class BtcMarketsOptions +{ + public required string ApiKey { get; set; } + public required string PrivateKey { get; set; } + public string BaseUrl { get; set; } = "https://api.btcmarkets.net"; +} diff --git a/src/BtcMarkets.Client/Exceptions/BtcMarketsException.cs b/src/BtcMarkets.Client/Exceptions/BtcMarketsException.cs new file mode 100644 index 0000000..8e0b52a --- /dev/null +++ b/src/BtcMarkets.Client/Exceptions/BtcMarketsException.cs @@ -0,0 +1,21 @@ +using System; + +namespace BtcMarkets.Client.Exceptions; + +public class BtcMarketsException : Exception +{ + public int? StatusCode { get; } + + public BtcMarketsException(string message) : base(message) + { + } + + public BtcMarketsException(string message, Exception innerException) : base(message, innerException) + { + } + + public BtcMarketsException(string message, int statusCode) : base(message) + { + StatusCode = statusCode; + } +} diff --git a/src/BtcMarkets.Client/IBtcMarketsClient.cs b/src/BtcMarkets.Client/IBtcMarketsClient.cs new file mode 100644 index 0000000..5151403 --- /dev/null +++ b/src/BtcMarkets.Client/IBtcMarketsClient.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using BtcMarkets.Client.Models; + +namespace BtcMarkets.Client; + +public interface IBtcMarketsClient +{ + Task GetOrdersAsync(string status = "all", int limit = 5, string? before = null, string? after = null); + Task PlaceNewOrderAsync(NewOrderModel order); + Task CancelOrderAsync(string orderId); + Task CancelAllOrdersAsync(string marketId = "BTC-AUD"); + // Expose raw methods if needed, or keep them internal/protected +} diff --git a/src/BtcMarkets.Client/Models/NewOrderModel.cs b/src/BtcMarkets.Client/Models/NewOrderModel.cs new file mode 100644 index 0000000..e726312 --- /dev/null +++ b/src/BtcMarkets.Client/Models/NewOrderModel.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; + +namespace BtcMarkets.Client.Models; + +public class NewOrderModel +{ + [JsonProperty("marketId")] + public required string MarketId { get; set; } + + [JsonProperty("price")] + public required string Price { get; set; } + + [JsonProperty("amount")] + public required string Amount { get; set; } + + [JsonProperty("type")] + public required string Type { get; set; } + + [JsonProperty("side")] + public required string Side { get; set; } + + [JsonProperty("triggerPrice")] + public string? TriggerPrice { get; set; } + + [JsonProperty("targetAmount")] + public string? TargetAmount { get; set; } + + [JsonProperty("timeInForce")] + public string? TimeInForce { get; set; } + + [JsonProperty("postOnly")] + public string? PostOnly { get; set; } + + [JsonProperty("selfTrade")] + public string? SelfTrade { get; set; } + + [JsonProperty("clientOrderId")] + public string? ClientOrderId { get; set; } +} diff --git a/src/BtcMarkets.Client/Models/ResponseModel.cs b/src/BtcMarkets.Client/Models/ResponseModel.cs new file mode 100644 index 0000000..40e6e6f --- /dev/null +++ b/src/BtcMarkets.Client/Models/ResponseModel.cs @@ -0,0 +1,9 @@ +using System.Net.Http.Headers; + +namespace BtcMarkets.Client.Models; + +public class ResponseModel +{ + public required HttpResponseHeaders Headers { get; set; } + public required string Content { get; set; } +} diff --git a/src/BtcMarkets.Client/ServiceCollectionExtensions.cs b/src/BtcMarkets.Client/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6731984 --- /dev/null +++ b/src/BtcMarkets.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace BtcMarkets.Client; + +public static class ServiceCollectionExtensions +{ + public static IHttpClientBuilder AddBtcMarkets(this IServiceCollection services, Action configureOptions) + { + services.Configure(configureOptions); + + return services.AddHttpClient() + .ConfigureHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + if (!string.IsNullOrEmpty(options.BaseUrl)) + { + client.BaseAddress = new Uri(options.BaseUrl); + } + }); + } +} diff --git a/src/BtcMarkets.ConsoleSample/BtcMarkets.ConsoleSample.csproj b/src/BtcMarkets.ConsoleSample/BtcMarkets.ConsoleSample.csproj new file mode 100644 index 0000000..a97456f --- /dev/null +++ b/src/BtcMarkets.ConsoleSample/BtcMarkets.ConsoleSample.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/src/BtcMarkets.ConsoleSample/Program.cs b/src/BtcMarkets.ConsoleSample/Program.cs new file mode 100644 index 0000000..9d77a41 --- /dev/null +++ b/src/BtcMarkets.ConsoleSample/Program.cs @@ -0,0 +1,69 @@ +using BtcMarkets.Client; +using BtcMarkets.Client.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure Logging +builder.Logging.AddConsole(); + +// Register BtcMarkets Client +builder.Services.AddBtcMarkets(options => +{ + options.BaseUrl = builder.Configuration["BtcMarkets:BaseUrl"] ?? "https://api.btcmarkets.net"; + options.ApiKey = builder.Configuration["BtcMarkets:ApiKey"] ?? string.Empty; + options.PrivateKey = builder.Configuration["BtcMarkets:PrivateKey"] ?? string.Empty; +}); + +using var host = builder.Build(); + +var client = host.Services.GetRequiredService(); +var logger = host.Services.GetRequiredService>(); + +Console.WriteLine("\n--- Starting Order Flow Demo (Library Version) ---"); + +try +{ + // 1. Place Order + var newOrder = new NewOrderModel + { + MarketId = "XRP-AUD", + Price = "1.00", + Amount = "15", + Type = "Limit", + Side = "Bid" + }; + + Console.WriteLine("Placing Order..."); + var placeResultJson = await client.PlaceNewOrderAsync(newOrder); + + // Parse + var placeResult = Newtonsoft.Json.Linq.JObject.Parse(placeResultJson); + var orderId = placeResult["orderId"]?.ToString(); + + if (string.IsNullOrEmpty(orderId)) + { + logger.LogError("Failed to get Order ID. Exiting demo."); + return; + } + + Console.WriteLine($"Order Placed. ID: {orderId}"); + + // 2. List Orders + Console.WriteLine("\nListing Open Orders..."); + var orders = await client.GetOrdersAsync("open"); + Console.WriteLine(orders.Content); + + // 3. Cancel Order + Console.WriteLine($"\nCanceling Order {orderId}..."); + var cancelResult = await client.CancelOrderAsync(orderId); + Console.WriteLine(cancelResult); + + Console.WriteLine("\n--- Order Flow Demo Complete ---"); +} +catch (Exception ex) +{ + logger.LogError(ex, "An error occurred during the demo flow"); +} diff --git a/tests/BtcMarkets.Client.Tests/BtcMarkets.Client.Tests.csproj b/tests/BtcMarkets.Client.Tests/BtcMarkets.Client.Tests.csproj new file mode 100644 index 0000000..c8b7d59 --- /dev/null +++ b/tests/BtcMarkets.Client.Tests/BtcMarkets.Client.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + + + + + + + + + + diff --git a/tests/BtcMarkets.Client.Tests/BtcMarketsClientTests.cs b/tests/BtcMarkets.Client.Tests/BtcMarketsClientTests.cs new file mode 100644 index 0000000..f3d73ef --- /dev/null +++ b/tests/BtcMarkets.Client.Tests/BtcMarketsClientTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BtcMarkets.Client.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using Xunit; + +namespace BtcMarkets.Client.Tests; + +public class BtcMarketsClientTests +{ + private readonly Mock _httpMessageHandlerMock; + private readonly HttpClient _httpClient; + private readonly Mock> _optionsMock; + private readonly Mock> _loggerMock; + private readonly BtcMarketsClient _client; + + public BtcMarketsClientTests() + { + _httpMessageHandlerMock = new Mock(); + _httpClient = new HttpClient(_httpMessageHandlerMock.Object) + { + BaseAddress = new Uri("https://api.btcmarkets.net") + }; + + _optionsMock = new Mock>(); + _optionsMock.Setup(o => o.Value).Returns(new BtcMarketsOptions + { + ApiKey = "test-api-key", + PrivateKey = "SGVsbG8gV29ybGQ=" // Base64 for "Hello World" + }); + + _loggerMock = new Mock>(); + + _client = new BtcMarketsClient(_httpClient, _optionsMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task GetOrdersAsync_ShouldReturnResponseModel_WhenApiCallIsSuccessful() + { + // Arrange + var expectedContent = "[{\"orderId\": \"123\"}]"; + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(expectedContent) + }); + + // Act + var result = await _client.GetOrdersAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedContent, result.Content); + + // Verify Headers were added + _httpMessageHandlerMock.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Headers.Contains("BM-AUTH-APIKEY") && + req.Headers.Contains("BM-AUTH-SIGNATURE") && + req.Headers.Contains("BM-AUTH-TIMESTAMP") + ), + ItExpr.IsAny() + ); + } + + [Fact] + public async Task PlaceNewOrderAsync_ShouldSignRequestCorrectly() + { + // Arrange + var newOrder = new NewOrderModel + { + MarketId = "BTC-AUD", + Price = "100000", + Amount = "0.1", + Type = "Limit", + Side = "Bid" + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"orderId\": \"123\"}") + }); + + // Act + await _client.PlaceNewOrderAsync(newOrder); + + // Assert + _httpMessageHandlerMock.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.Headers.Contains("BM-AUTH-SIGNATURE") + ), + ItExpr.IsAny() + ); + } +}