V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -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 }
}
};
}
}
}
+57 -2
View File
@@ -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;