// ----------------------------------------------------------------------------- // FeedSnapshotEndpointExtensions.cs // Sprint: SPRINT_20251226_007_BE_determinism_gaps // Task: DET-GAP-03 // Description: Feed snapshot endpoint for atomic multi-source snapshots // ----------------------------------------------------------------------------- #pragma warning disable ASPDEPR002 // WithOpenApi is deprecated - will migrate to new OpenAPI approach using System.Net.Mime; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using StellaOps.Concelier.WebService.Options; using StellaOps.Concelier.WebService.Results; using StellaOps.Replay.Core.FeedSnapshot; using HttpResults = Microsoft.AspNetCore.Http.Results; namespace StellaOps.Concelier.WebService.Extensions; /// /// Endpoint extensions for feed snapshot functionality. /// Provides atomic multi-source snapshots with composite digest. /// Per DET-GAP-03 in SPRINT_20251226_007_BE_determinism_gaps. /// internal static class FeedSnapshotEndpointExtensions { /// /// Maps feed snapshot endpoints to the application. /// public static void MapFeedSnapshotEndpoints(this WebApplication app) { var group = app.MapGroup("/api/v1/feeds/snapshot") .WithTags("FeedSnapshot") .WithOpenApi(); // POST /api/v1/feeds/snapshot - Create atomic snapshot group.MapPost("/", CreateSnapshotAsync) .WithName("CreateFeedSnapshot") .WithSummary("Create an atomic feed snapshot") .WithDescription("Creates an atomic snapshot of all registered feed sources with a composite digest."); // GET /api/v1/feeds/snapshot - List available snapshots group.MapGet("/", ListSnapshotsAsync) .WithName("ListFeedSnapshots") .WithSummary("List available feed snapshots") .WithDescription("Returns a list of available feed snapshots with metadata."); // GET /api/v1/feeds/snapshot/{snapshotId} - Get snapshot details group.MapGet("/{snapshotId}", GetSnapshotAsync) .WithName("GetFeedSnapshot") .WithSummary("Get feed snapshot details") .WithDescription("Returns detailed information about a specific feed snapshot."); // GET /api/v1/feeds/snapshot/{snapshotId}/export - Export snapshot bundle group.MapGet("/{snapshotId}/export", ExportSnapshotAsync) .WithName("ExportFeedSnapshot") .WithSummary("Export feed snapshot bundle") .WithDescription("Downloads the snapshot bundle as a compressed archive for offline use."); // POST /api/v1/feeds/snapshot/import - Import snapshot bundle group.MapPost("/import", ImportSnapshotAsync) .WithName("ImportFeedSnapshot") .WithSummary("Import feed snapshot bundle") .WithDescription("Imports a snapshot bundle from a compressed archive."); // GET /api/v1/feeds/snapshot/{snapshotId}/validate - Validate snapshot group.MapGet("/{snapshotId}/validate", ValidateSnapshotAsync) .WithName("ValidateFeedSnapshot") .WithSummary("Validate feed snapshot integrity") .WithDescription("Validates the integrity of a feed snapshot against current feed state."); // GET /api/v1/feeds/sources - List registered feed sources group.MapGet("/sources", ListSourcesAsync) .WithName("ListFeedSources") .WithSummary("List registered feed sources") .WithDescription("Returns a list of registered feed sources available for snapshots."); } private static async Task CreateSnapshotAsync( HttpContext context, [FromServices] IFeedSnapshotCoordinator coordinator, [FromServices] IOptionsMonitor optionsMonitor, [FromBody] CreateSnapshotRequest? request, CancellationToken cancellationToken) { var options = optionsMonitor.CurrentValue; // Check if feed snapshot feature is enabled if (!options.FeedSnapshot.Enabled) { return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot"); } // Validate requested sources if provided if (request?.Sources is { Length: > 0 }) { var registeredSources = coordinator.RegisteredSources; var invalidSources = request.Sources .Where(s => !registeredSources.Contains(s, StringComparer.OrdinalIgnoreCase)) .ToArray(); if (invalidSources.Length > 0) { return ConcelierProblemResultFactory.InvalidSources( context, invalidSources, registeredSources); } } var bundle = await coordinator.CreateSnapshotAsync( request?.Label, cancellationToken).ConfigureAwait(false); var response = new CreateSnapshotResponse( bundle.SnapshotId, bundle.CompositeDigest, bundle.CreatedAt, bundle.Sources.Select(s => new SourceSnapshotSummary( s.SourceId, s.Digest, s.ItemCount)).ToArray()); return HttpResults.Created( $"/api/v1/feeds/snapshot/{bundle.SnapshotId}", response); } private static async Task ListSnapshotsAsync( HttpContext context, [FromServices] IFeedSnapshotCoordinator coordinator, [FromServices] IOptionsMonitor optionsMonitor, [FromQuery] int? limit, [FromQuery] string? cursor, CancellationToken cancellationToken) { var options = optionsMonitor.CurrentValue; if (!options.FeedSnapshot.Enabled) { return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot"); } var effectiveLimit = Math.Min( Math.Max(limit ?? 50, 1), options.FeedSnapshot.MaxListPageSize); var summaries = await coordinator.ListSnapshotsAsync( cursor, effectiveLimit, cancellationToken).ConfigureAwait(false); var response = new ListSnapshotsResponse( summaries.Select(s => new SnapshotListItem( s.SnapshotId, s.CompositeDigest, s.CreatedAt, s.Label, s.SourceCount, s.TotalItemCount)).ToArray()); return HttpResults.Ok(response); } private static async Task GetSnapshotAsync( HttpContext context, [FromServices] IFeedSnapshotCoordinator coordinator, [FromServices] IOptionsMonitor optionsMonitor, string snapshotId, CancellationToken cancellationToken) { var options = optionsMonitor.CurrentValue; if (!options.FeedSnapshot.Enabled) { return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot"); } var bundle = await coordinator.GetSnapshotAsync(snapshotId, cancellationToken) .ConfigureAwait(false); if (bundle is null) { return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId); } var response = new GetSnapshotResponse( bundle.SnapshotId, bundle.CompositeDigest, bundle.CreatedAt, bundle.Label, bundle.Sources.Select(s => new SourceSnapshotDetail( s.SourceId, s.Digest, s.ItemCount, s.CreatedAt)).ToArray()); return HttpResults.Ok(response); } private static async Task ExportSnapshotAsync( HttpContext context, [FromServices] IFeedSnapshotCoordinator coordinator, [FromServices] IOptionsMonitor optionsMonitor, string snapshotId, [FromQuery] string? format, CancellationToken cancellationToken) { var options = optionsMonitor.CurrentValue; if (!options.FeedSnapshot.Enabled) { return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot"); } var exportOptions = new ExportBundleOptions { Compression = ParseCompression(format), IncludeManifest = true, IncludeChecksums = true }; var metadata = await coordinator.ExportBundleAsync( snapshotId, exportOptions, cancellationToken).ConfigureAwait(false); if (metadata is null || metadata.ExportPath is null) { return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId); } context.Response.Headers.ContentDisposition = $"attachment; filename=\"snapshot-{snapshotId}.tar.{GetExtension(exportOptions.Compression)}\""; return HttpResults.File( metadata.ExportPath, MediaTypeNames.Application.Octet, $"snapshot-{snapshotId}.tar.{GetExtension(exportOptions.Compression)}"); } private static async Task ImportSnapshotAsync( HttpContext context, [FromServices] IFeedSnapshotCoordinator coordinator, [FromServices] IOptionsMonitor optionsMonitor, IFormFile file, [FromQuery] bool? validate, CancellationToken cancellationToken) { var options = optionsMonitor.CurrentValue; if (!options.FeedSnapshot.Enabled) { return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot"); } if (file.Length == 0) { return ConcelierProblemResultFactory.EmptyFile(context); } if (file.Length > options.FeedSnapshot.MaxBundleSizeBytes) { return ConcelierProblemResultFactory.FileTooLarge( context, file.Length, options.FeedSnapshot.MaxBundleSizeBytes); } await using var stream = file.OpenReadStream(); var importOptions = new ImportBundleOptions { ValidateDigests = validate ?? true }; var bundle = await coordinator.ImportBundleAsync( stream, importOptions, cancellationToken).ConfigureAwait(false); var response = new ImportSnapshotResponse( bundle.SnapshotId, bundle.CompositeDigest, bundle.CreatedAt, bundle.Sources.Count); return HttpResults.Created( $"/api/v1/feeds/snapshot/{bundle.SnapshotId}", response); } private static async Task ValidateSnapshotAsync( HttpContext context, [FromServices] IFeedSnapshotCoordinator coordinator, [FromServices] IOptionsMonitor optionsMonitor, string snapshotId, CancellationToken cancellationToken) { var options = optionsMonitor.CurrentValue; if (!options.FeedSnapshot.Enabled) { return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot"); } var result = await coordinator.ValidateSnapshotAsync(snapshotId, cancellationToken) .ConfigureAwait(false); if (result is null) { return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId); } var response = new ValidateSnapshotResponse( result.IsValid, result.SnapshotDigest, result.CurrentDigest, result.DriftedSources.Select(d => new DriftedSourceInfo( d.SourceId, d.SnapshotDigest, d.CurrentDigest, d.AddedItems, d.RemovedItems, d.ModifiedItems)).ToArray()); return HttpResults.Ok(response); } private static IResult ListSourcesAsync( HttpContext context, [FromServices] IFeedSnapshotCoordinator coordinator, [FromServices] IOptionsMonitor optionsMonitor) { var options = optionsMonitor.CurrentValue; if (!options.FeedSnapshot.Enabled) { return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot"); } var sources = coordinator.RegisteredSources; return HttpResults.Ok(new ListSourcesResponse(sources.ToArray())); } private static CompressionAlgorithm ParseCompression(string? format) { return format?.ToUpperInvariant() switch { "ZSTD" => CompressionAlgorithm.Zstd, "GZIP" or "GZ" => CompressionAlgorithm.Gzip, "NONE" => CompressionAlgorithm.None, _ => CompressionAlgorithm.Zstd // Default to Zstd }; } private static string GetExtension(CompressionAlgorithm compression) { return compression switch { CompressionAlgorithm.Zstd => "zst", CompressionAlgorithm.Gzip => "gz", _ => "tar" }; } } // ---- Request/Response DTOs ---- /// Request to create a feed snapshot. /// Optional human-readable label for the snapshot. /// Optional list of source IDs to include. If null, all registered sources are included. public sealed record CreateSnapshotRequest( string? Label, string[]? Sources); /// Response after creating a feed snapshot. public sealed record CreateSnapshotResponse( string SnapshotId, string CompositeDigest, DateTimeOffset CreatedAt, SourceSnapshotSummary[] Sources); /// Summary of a source in a snapshot. public sealed record SourceSnapshotSummary( string SourceId, string Digest, int ItemCount); /// Response listing available snapshots. public sealed record ListSnapshotsResponse( SnapshotListItem[] Snapshots); /// Item in the snapshot list. public sealed record SnapshotListItem( string SnapshotId, string CompositeDigest, DateTimeOffset CreatedAt, string? Label, int SourceCount, int TotalItemCount); /// Response with snapshot details. public sealed record GetSnapshotResponse( string SnapshotId, string CompositeDigest, DateTimeOffset CreatedAt, string? Label, SourceSnapshotDetail[] Sources); /// Detailed info about a source in a snapshot. public sealed record SourceSnapshotDetail( string SourceId, string Digest, int ItemCount, DateTimeOffset CreatedAt); /// Response after importing a snapshot. public sealed record ImportSnapshotResponse( string SnapshotId, string CompositeDigest, DateTimeOffset CreatedAt, int SourceCount); /// Response from snapshot validation. public sealed record ValidateSnapshotResponse( bool IsValid, string SnapshotDigest, string CurrentDigest, DriftedSourceInfo[] DriftedSources); /// Info about a drifted source. public sealed record DriftedSourceInfo( string SourceId, string SnapshotDigest, string CurrentDigest, int AddedItems, int RemovedItems, int ModifiedItems); /// Response listing registered sources. public sealed record ListSourcesResponse( string[] Sources);