208 lines
8.5 KiB
C#
208 lines
8.5 KiB
C#
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Notifier.WebService.Constants;
|
|
using StellaOps.Notifier.WebService.Extensions;
|
|
using StellaOps.Notifier.Worker.Fallback;
|
|
using StellaOps.Notify.Models;
|
|
|
|
namespace StellaOps.Notifier.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// REST API endpoints for fallback handler operations.
|
|
/// </summary>
|
|
public static class FallbackEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps fallback API endpoints.
|
|
/// </summary>
|
|
public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
var group = endpoints.MapGroup("/api/v2/fallback")
|
|
.WithTags("Fallback")
|
|
.WithOpenApi()
|
|
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
|
.RequireTenant();
|
|
|
|
// Get fallback statistics
|
|
group.MapGet("/statistics", async (
|
|
int? windowHours,
|
|
HttpContext context,
|
|
IFallbackHandler fallbackHandler,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
|
var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null;
|
|
|
|
var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
stats.TenantId,
|
|
window = stats.Window.ToString(),
|
|
stats.TotalDeliveries,
|
|
stats.PrimarySuccesses,
|
|
stats.FallbackAttempts,
|
|
stats.FallbackSuccesses,
|
|
stats.ExhaustedDeliveries,
|
|
successRate = $"{stats.SuccessRate:P1}",
|
|
fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}",
|
|
failuresByChannel = stats.FailuresByChannel.ToDictionary(
|
|
kvp => kvp.Key.ToString(),
|
|
kvp => kvp.Value)
|
|
});
|
|
})
|
|
.WithName("GetFallbackStatistics")
|
|
.WithSummary("Gets fallback handling statistics for a tenant")
|
|
.WithDescription("Returns aggregate delivery statistics for the tenant including primary success rate, fallback attempt count, fallback success rate, and per-channel failure breakdown over the specified window.");
|
|
|
|
// Get fallback chain for a channel
|
|
group.MapGet("/chains/{channelType}", async (
|
|
NotifyChannelType channelType,
|
|
HttpContext context,
|
|
IFallbackHandler fallbackHandler,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
|
|
|
var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
tenantId,
|
|
primaryChannel = channelType.ToString(),
|
|
fallbackChain = chain.Select(c => c.ToString()).ToList(),
|
|
chainLength = chain.Count
|
|
});
|
|
})
|
|
.WithName("GetFallbackChain")
|
|
.WithSummary("Gets the fallback chain for a channel type")
|
|
.WithDescription("Returns the ordered list of fallback channel types that will be tried when the primary channel fails. If no custom chain is configured, the system default is returned.");
|
|
|
|
// Set fallback chain for a channel
|
|
group.MapPut("/chains/{channelType}", async (
|
|
NotifyChannelType channelType,
|
|
SetFallbackChainRequest request,
|
|
HttpContext context,
|
|
IFallbackHandler fallbackHandler,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
|
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
|
|
|
var chain = request.FallbackChain
|
|
.Select(s => Enum.TryParse<NotifyChannelType>(s, out var t) ? t : (NotifyChannelType?)null)
|
|
.Where(t => t.HasValue)
|
|
.Select(t => t!.Value)
|
|
.ToList();
|
|
|
|
await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
message = "Fallback chain updated successfully",
|
|
primaryChannel = channelType.ToString(),
|
|
fallbackChain = chain.Select(c => c.ToString()).ToList()
|
|
});
|
|
})
|
|
.WithName("SetFallbackChain")
|
|
.WithSummary("Sets a custom fallback chain for a channel type")
|
|
.WithDescription("Creates or replaces the fallback chain for a primary channel type. The chain must reference valid channel types; invalid entries are silently filtered out.")
|
|
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
|
|
|
// Test fallback resolution
|
|
group.MapPost("/test", async (
|
|
TestFallbackRequest request,
|
|
HttpContext context,
|
|
IFallbackHandler fallbackHandler,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
|
|
|
if (!Enum.TryParse<NotifyChannelType>(request.FailedChannelType, out var channelType))
|
|
{
|
|
return Results.BadRequest(new { error = $"Invalid channel type: {request.FailedChannelType}" });
|
|
}
|
|
|
|
var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
|
|
|
|
// Simulate failure recording
|
|
await fallbackHandler.RecordFailureAsync(
|
|
tenantId, deliveryId, channelType, "Test failure", cancellationToken);
|
|
|
|
// Get fallback result
|
|
var result = await fallbackHandler.GetFallbackAsync(
|
|
tenantId, channelType, deliveryId, cancellationToken);
|
|
|
|
// Clean up test state
|
|
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
testDeliveryId = deliveryId,
|
|
result.HasFallback,
|
|
nextChannelType = result.NextChannelType?.ToString(),
|
|
result.AttemptNumber,
|
|
result.TotalChannels,
|
|
result.IsExhausted,
|
|
result.ExhaustionReason,
|
|
failedChannels = result.FailedChannels.Select(f => new
|
|
{
|
|
channelType = f.ChannelType.ToString(),
|
|
f.Reason,
|
|
f.FailedAt,
|
|
f.AttemptNumber
|
|
}).ToList()
|
|
});
|
|
})
|
|
.WithName("TestFallback")
|
|
.WithSummary("Tests fallback resolution without affecting real deliveries")
|
|
.WithDescription("Simulates a channel failure for the specified channel type and returns which fallback channel would be selected next. The simulated delivery state is cleaned up after the test.")
|
|
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
|
|
|
// Clear delivery state
|
|
group.MapDelete("/deliveries/{deliveryId}", async (
|
|
string deliveryId,
|
|
HttpContext context,
|
|
IFallbackHandler fallbackHandler,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
|
|
|
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
|
|
|
return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" });
|
|
})
|
|
.WithName("ClearDeliveryFallbackState")
|
|
.WithSummary("Clears fallback state for a specific delivery")
|
|
.WithDescription("Removes all in-memory fallback tracking state for a delivery ID. Use this to reset a stuck delivery that has exhausted its fallback chain without entering a terminal status.")
|
|
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
|
|
|
return group;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to set a custom fallback chain.
|
|
/// </summary>
|
|
public sealed record SetFallbackChainRequest
|
|
{
|
|
/// <summary>
|
|
/// Ordered list of fallback channel types.
|
|
/// </summary>
|
|
public required List<string> FallbackChain { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to test fallback resolution.
|
|
/// </summary>
|
|
public sealed record TestFallbackRequest
|
|
{
|
|
/// <summary>
|
|
/// The channel type that "failed".
|
|
/// </summary>
|
|
public required string FailedChannelType { get; init; }
|
|
}
|