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;
|
||||
|
||||
Reference in New Issue
Block a user