V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
@@ -60,7 +60,6 @@ public sealed class TriggerIngestionEndpoint : Endpoint<IngestionRequest, Ingest
|
||||
{
|
||||
Post("/api/market/ingest");
|
||||
Roles("DataAdmin");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(IngestionRequest req, CancellationToken ct)
|
||||
@@ -113,7 +112,7 @@ public sealed class GetIngestionStatusEndpoint : EndpointWithoutRequest<Ingestio
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/market/ingest/{jobId}");
|
||||
AllowAnonymous();
|
||||
Roles("DataAdmin");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
|
||||
@@ -71,7 +71,6 @@ public sealed class TriggerRebalanceEndpoint : Endpoint<RebalanceRequest, Rebala
|
||||
{
|
||||
Post("/api/portfolio/{portfolioId}/rebalance");
|
||||
Roles("PortfolioManager");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RebalanceRequest req, CancellationToken ct)
|
||||
@@ -120,7 +119,7 @@ public sealed class GetCompositionEndpoint : EndpointWithoutRequest<PortfolioCom
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/portfolio/{portfolioId}/composition");
|
||||
AllowAnonymous();
|
||||
Roles("PortfolioManager");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class GetRiskMetricsEndpoint : EndpointWithoutRequest<RiskMetricsR
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/portfolio/{portfolioId}/risk");
|
||||
AllowAnonymous();
|
||||
Roles("RiskAnalyst");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
|
||||
@@ -49,7 +49,6 @@ public sealed class TriggerStressTestEndpoint : Endpoint<TriggerStressTestReques
|
||||
{
|
||||
Post("/api/portfolio/{portfolioId}/stress");
|
||||
Roles("RiskAnalyst");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(TriggerStressTestRequest req, CancellationToken ct)
|
||||
@@ -208,7 +207,7 @@ public sealed class GetAlertsEndpoint : EndpointWithoutRequest<GetAlertsResponse
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/portfolio/{portfolioId}/alerts");
|
||||
AllowAnonymous();
|
||||
Roles("RiskAnalyst");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
|
||||
@@ -86,7 +86,7 @@ public sealed class GetRiskDashboardEndpoint : EndpointWithoutRequest<DashboardR
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/dashboard/risk");
|
||||
AllowAnonymous();
|
||||
Roles("RiskAnalyst");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
|
||||
@@ -68,7 +68,8 @@ public class InitiateShadowRunEndpoint : Endpoint<InitiateShadowRunRequest, Init
|
||||
/// POST /api/test/shadow-run-direct
|
||||
/// Direct synchronous test execution (bypass Hangfire queue)
|
||||
/// </summary>
|
||||
public class TestDirectShadowRunEndpoint : Endpoint<InitiateShadowRunRequest, object>
|
||||
[DontRegister]
|
||||
public class TestDirectShadowRunEndpoint(IClock clock) : Endpoint<InitiateShadowRunRequest, object>
|
||||
{
|
||||
private ILogger<TestDirectShadowRunEndpoint>? _logger;
|
||||
|
||||
@@ -99,11 +100,11 @@ public class TestDirectShadowRunEndpoint : Endpoint<InitiateShadowRunRequest, ob
|
||||
PhaseFilter: MarketPhaseFilter.All);
|
||||
|
||||
_logger.LogInformation("🚀 Executing ShadowRunJob directly (no queue)...");
|
||||
var startTime = DateTime.UtcNow;
|
||||
var startTime = clock.UtcNow;
|
||||
|
||||
await job.ExecuteAsync(command, ct);
|
||||
|
||||
var duration = DateTime.UtcNow - startTime;
|
||||
var duration = clock.UtcNow - startTime;
|
||||
_logger.LogInformation("✅ Direct execution completed in {Duration}ms", duration.TotalMilliseconds);
|
||||
|
||||
HttpContext.Response.StatusCode = 200;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace KArtSell.Host.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Establishes one correlation identifier at the HTTP boundary.
|
||||
/// Endpoint code may use TraceIdentifier or Items[CorrelationId], but both resolve
|
||||
/// to the same value and the response exposes it for support/replay tracing.
|
||||
/// </summary>
|
||||
public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var correlationId = ReadCorrelationId(context.Request.Headers["X-Correlation-Id"]);
|
||||
|
||||
context.TraceIdentifier = correlationId.ToString("D");
|
||||
context.Items["CorrelationId"] = correlationId;
|
||||
context.Response.Headers["X-Correlation-Id"] = correlationId.ToString("D");
|
||||
Activity.Current?.SetTag("kartsell.correlation_id", correlationId.ToString("D"));
|
||||
|
||||
using (logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["CorrelationId"] = correlationId,
|
||||
}))
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid ReadCorrelationId(string? headerValue) =>
|
||||
Guid.TryParse(headerValue, out var correlationId) && correlationId != Guid.Empty
|
||||
? correlationId
|
||||
: Guid.NewGuid();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace KArtSell.Host.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// Documents the error family produced by the Host's ProblemDetails,
|
||||
/// authentication, authorization, routing, and conflict boundaries.
|
||||
/// </summary>
|
||||
public sealed class ProblemDetailsOperationFilter : IOperationFilter
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> Responses =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["400"] = "Invalid request",
|
||||
["401"] = "Authentication required",
|
||||
["403"] = "Permission denied",
|
||||
["404"] = "Resource not found",
|
||||
["409"] = "Concurrency or business conflict",
|
||||
["500"] = "Unexpected server error"
|
||||
};
|
||||
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
var schema = context.SchemaGenerator.GenerateSchema(
|
||||
typeof(ProblemDetails), context.SchemaRepository);
|
||||
operation.Responses ??= new OpenApiResponses();
|
||||
operation.Description = string.Join(
|
||||
Environment.NewLine,
|
||||
operation.Description,
|
||||
"ProblemDetails types: validation, business-rule, conflict, permission, not-found, integration, system.",
|
||||
"Correlation: X-Correlation-Id.");
|
||||
|
||||
foreach (var (status, description) in Responses)
|
||||
{
|
||||
if (operation.Responses.ContainsKey(status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
operation.Responses[status] = new OpenApiResponse
|
||||
{
|
||||
Description = description,
|
||||
Content = new Dictionary<string, OpenApiMediaType>
|
||||
{
|
||||
["application/problem+json"] = new() { Schema = schema }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using KArtSell.Host.Jobs;
|
||||
using KArtSell.Host.Configuration;
|
||||
using KArtSell.Host.Infrastructure;
|
||||
using KArtSell.Host.OpenApi;
|
||||
using KArtSell.Host.Observability;
|
||||
using KArtSell.Host.Features.Observability;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
@@ -16,6 +17,7 @@ using KArtSell.Modules.ModelOperations;
|
||||
using KArtSell.Modules.ModelOperations.Scheduling;
|
||||
using KArtSell.Modules.SignalEngine;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.OpenApi;
|
||||
using Npgsql;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
@@ -24,6 +26,7 @@ using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var openApiGenerationRequested = TryGetOpenApiOutputPath(args, out _);
|
||||
|
||||
// Load Telegram secrets for Serilog notifications
|
||||
var telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty;
|
||||
@@ -256,13 +259,21 @@ builder.Services.AddSignalEngineModule();
|
||||
builder.Services.AddModelOperationsModule();
|
||||
builder.Services.AddFastEndpoints(); // AFTER modules registered (so their endpoints are included)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
// FastEndpoints commonly nests DTOs as Endpoint.Response. Type names alone
|
||||
// collide across slices; a stable full-name id keeps the generated contract
|
||||
// deterministic without changing the wire DTO shape.
|
||||
options.CustomSchemaIds(type => type.FullName?.Replace('+', '.') ?? type.Name);
|
||||
options.OperationFilter<ProblemDetailsOperationFilter>();
|
||||
});
|
||||
|
||||
builder.Services.AddHangfire(config => config.UsePostgreSqlStorage(options =>
|
||||
options.UseNpgsqlConnection(connectionString)));
|
||||
|
||||
// Hangfire server can be disabled via HANGFIRE_SERVER_ENABLED=false (useful for testing/debugging port binding)
|
||||
var hangfireServerEnabled = Environment.GetEnvironmentVariable("HANGFIRE_SERVER_ENABLED") != "false";
|
||||
var hangfireServerEnabled = !openApiGenerationRequested
|
||||
&& Environment.GetEnvironmentVariable("HANGFIRE_SERVER_ENABLED") != "false";
|
||||
if (hangfireServerEnabled)
|
||||
{
|
||||
builder.Services.AddHangfireServer(options =>
|
||||
@@ -300,6 +311,7 @@ var app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseMiddleware<RateLimiterMiddleware>(); // Rate limiting middleware
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -328,6 +340,32 @@ app.MapGet("/health/ready", async (NpgsqlDataSource source, CancellationToken ct
|
||||
return Results.Ok(new { status = "ready", database = "reachable" });
|
||||
});
|
||||
|
||||
if (TryGetOpenApiOutputPath(args, out var openApiOutputPath))
|
||||
{
|
||||
await app.StartAsync();
|
||||
try
|
||||
{
|
||||
var swaggerProvider = app.Services.GetRequiredService<Swashbuckle.AspNetCore.Swagger.ISwaggerProvider>();
|
||||
var document = swaggerProvider.GetSwagger("v1");
|
||||
var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(openApiOutputPath));
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
await using var output = File.CreateText(openApiOutputPath);
|
||||
var writer = new OpenApiJsonWriter(output);
|
||||
document.SerializeAsV3(writer);
|
||||
await output.FlushAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Start app in background and register Hangfire jobs after Kestrel binds
|
||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
var runTask = app.RunAsync();
|
||||
@@ -438,4 +476,21 @@ static string? ResolveSecret(string? configValue, string environmentVariable)
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool TryGetOpenApiOutputPath(string[] arguments, out string outputPath)
|
||||
{
|
||||
const string switchName = "--output";
|
||||
outputPath = string.Empty;
|
||||
var generateRequested = arguments.Any(argument =>
|
||||
string.Equals(argument, "--generate-openapi-spec-only", StringComparison.Ordinal));
|
||||
var outputIndex = Array.FindIndex(arguments, argument =>
|
||||
string.Equals(argument, switchName, StringComparison.Ordinal));
|
||||
if (!generateRequested || outputIndex < 0 || outputIndex + 1 >= arguments.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
outputPath = arguments[outputIndex + 1];
|
||||
return !string.IsNullOrWhiteSpace(outputPath) && !outputPath.StartsWith("--", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -59,7 +59,7 @@ public class QueryAuditEventsEndpoint : Endpoint<QueryAuditEventsRequest, QueryA
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/audit/events");
|
||||
AllowAnonymous(); // RBAC enforced at handler level (Compliance Officer role)
|
||||
Roles("Compliance");
|
||||
Summary(x =>
|
||||
{
|
||||
x.Summary = "Query Audit Events";
|
||||
|
||||
@@ -39,7 +39,7 @@ public class SubmitGdprRequestEndpoint : Endpoint<SubmitGdprRequestDto, GdprRequ
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/compliance/gdpr-request");
|
||||
AllowAnonymous(); // RBAC enforced at handler level (Data Admin/Compliance Officer role)
|
||||
Roles("DataAdmin", "Compliance");
|
||||
Summary(x =>
|
||||
{
|
||||
x.Summary = "Submit GDPR Request";
|
||||
|
||||
@@ -22,7 +22,7 @@ public class CreateApprovalEndpoint : EndpointWithoutRequest<CreateApprovalRespo
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/approvals");
|
||||
AllowAnonymous();
|
||||
Roles("Maker");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
@@ -51,7 +51,7 @@ public class GetApprovalsEndpoint : Endpoint<GetApprovalsRequest, GetApprovalsRe
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/approvals");
|
||||
AllowAnonymous();
|
||||
Roles("Maker", "Checker", "SRE");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetApprovalsRequest req, CancellationToken ct)
|
||||
@@ -76,7 +76,7 @@ public class ProposeForReviewEndpoint : EndpointWithoutRequest<ProposeForReviewR
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/approvals/{id}/propose");
|
||||
AllowAnonymous();
|
||||
Roles("Maker");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
@@ -111,7 +111,7 @@ public class ActivateApprovalEndpoint : EndpointWithoutRequest<ActivateApprovalR
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/approvals/{id}/activate");
|
||||
AllowAnonymous();
|
||||
Roles("SRE");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
@@ -140,7 +140,7 @@ public class GetApprovalByIdEndpoint : EndpointWithoutRequest<ApprovalDetailResp
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/approvals/{id}");
|
||||
AllowAnonymous();
|
||||
Roles("Maker", "Checker", "SRE");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
@@ -183,7 +183,7 @@ public class ApproveApprovalEndpoint : Endpoint<ApproveApprovalRequest, ApproveA
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/approvals/{id}/approve");
|
||||
AllowAnonymous();
|
||||
Roles("Checker");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ApproveApprovalRequest req, CancellationToken ct)
|
||||
|
||||
@@ -6,10 +6,12 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FastEndpoints;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
/// <summary>
|
||||
/// GET /reconciliation/holdings - Returns current portfolio holdings
|
||||
/// </summary>
|
||||
[DontRegister]
|
||||
public class GetHoldingsEndpoint : EndpointWithoutRequest<GetHoldingsResponse>
|
||||
{
|
||||
private readonly IReconciliationRepository _repository;
|
||||
@@ -74,6 +76,7 @@ public class HoldingDto
|
||||
/// <summary>
|
||||
/// GET /reconciliation/mismatches - Returns flagged discrepancies
|
||||
/// </summary>
|
||||
[DontRegister]
|
||||
public class GetMismatchesEndpoint : EndpointWithoutRequest<GetMismatchesResponse>
|
||||
{
|
||||
private readonly IReconciliationRepository _repository;
|
||||
@@ -148,6 +151,7 @@ public class MismatchDto
|
||||
/// <summary>
|
||||
/// POST /reconciliation/reconcile-trade - Trigger trade reconciliation
|
||||
/// </summary>
|
||||
[DontRegister]
|
||||
public class ReconcileTradeEndpoint : Endpoint<ReconcileTradeRequest>
|
||||
{
|
||||
private readonly ReconcileTradeHandler _handler;
|
||||
@@ -165,6 +169,17 @@ public class ReconcileTradeEndpoint : Endpoint<ReconcileTradeRequest>
|
||||
|
||||
public override async Task HandleAsync(ReconcileTradeRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!ReconcileTradeRequestContract.HasIdempotencyKey(request))
|
||||
{
|
||||
await Send.ResponseAsync(new
|
||||
{
|
||||
type = "https://httpstatuses.com/400",
|
||||
title = "Invalid reconciliation request",
|
||||
detail = "Idempotency-Key is required for replay-safe reconciliation."
|
||||
}, StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var command = new ReconcileTradeCommand
|
||||
{
|
||||
TradeId = request.TradeId,
|
||||
@@ -186,6 +201,12 @@ public class ReconcileTradeEndpoint : Endpoint<ReconcileTradeRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public static class ReconcileTradeRequestContract
|
||||
{
|
||||
public static bool HasIdempotencyKey(ReconcileTradeRequest request) =>
|
||||
request is not null && !string.IsNullOrWhiteSpace(request.IdempotencyKey);
|
||||
}
|
||||
|
||||
public class ReconcileTradeRequest
|
||||
{
|
||||
public Guid TradeId { get; set; }
|
||||
@@ -204,6 +225,7 @@ public class ReconcileTradeRequest
|
||||
/// <summary>
|
||||
/// GET /reconciliation/report/daily - Returns daily reconciliation report
|
||||
/// </summary>
|
||||
[DontRegister]
|
||||
public class GetDailyReportEndpoint : EndpointWithoutRequest<ReconciliationReportDto>
|
||||
{
|
||||
private readonly ReconciliationEngine _engine;
|
||||
|
||||
+5
-1
@@ -51,6 +51,10 @@ public class ReconcileTradeHandler
|
||||
public async Task HandleAsync(ReconcileTradeCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
|
||||
{
|
||||
throw new ArgumentException("IdempotencyKey is required for replay-safe reconciliation.", nameof(command));
|
||||
}
|
||||
|
||||
// DEBT-018: share one connection/transaction across the engine's holding/log writes and
|
||||
// the outbox event(s) below, instead of the engine writing on its own connection and the
|
||||
@@ -93,7 +97,7 @@ public class ReconcileTradeHandler
|
||||
: null,
|
||||
ReconciliationTimestamp = _clock.UtcNow.UtcDateTime,
|
||||
CorrelationId = command.CorrelationId,
|
||||
IdempotencyKey = command.IdempotencyKey ?? Guid.NewGuid().ToString()
|
||||
IdempotencyKey = command.IdempotencyKey
|
||||
};
|
||||
|
||||
await PublishAsync(transaction, "TradeReconciled", @event, command.CorrelationId, CancellationToken.None);
|
||||
|
||||
@@ -18,7 +18,6 @@ public class CreateSellDecisionEndpoint : Endpoint<CreateSellDecisionRequest, Cr
|
||||
{
|
||||
Post("/sell-decisions");
|
||||
Roles("Maker");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateSellDecisionRequest req, CancellationToken ct)
|
||||
@@ -45,7 +44,6 @@ public class ListSellDecisionsEndpoint : EndpointWithoutRequest<ListSellDecision
|
||||
{
|
||||
Get("/sell-decisions");
|
||||
Roles("Quant", "Maker", "Checker");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
@@ -112,7 +110,6 @@ public class ExecuteSellDecisionEndpoint : Endpoint<ExecuteSellDecisionRequest,
|
||||
{
|
||||
Post("/sell-decisions/{id}/execute");
|
||||
Roles("Maker", "Checker");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ExecuteSellDecisionRequest req, CancellationToken ct)
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.TradeExecution;
|
||||
|
||||
[DontRegister]
|
||||
public class CreateTradeEndpoint : Endpoint<CreateTradeRequest, CreateTradeResponse>
|
||||
{
|
||||
private readonly SubmitTradeHandler _handler;
|
||||
@@ -53,6 +54,7 @@ public class CreateTradeEndpoint : Endpoint<CreateTradeRequest, CreateTradeRespo
|
||||
}
|
||||
}
|
||||
|
||||
[DontRegister]
|
||||
public class ListTradesEndpoint : Endpoint<EmptyRequest, ListTradesResponse>
|
||||
{
|
||||
private readonly ITradeSql _sql;
|
||||
|
||||
Reference in New Issue
Block a user