Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Endpoints/FallbackEndpoints.cs

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; }
}