// Copyright (c) StellaOps. Licensed under the BUSL-1.1. using Microsoft.AspNetCore.Http.HttpResults; using StellaOps.Timeline.Core; using StellaOps.Timeline.Core.Export; using StellaOps.HybridLogicalClock; using StellaOps.Timeline.WebService.Security; using StellaOps.Auth.ServerIntegration.Tenancy; namespace StellaOps.Timeline.WebService.Endpoints; /// /// Export endpoints for timeline bundles. /// public static class ExportEndpoints { /// /// Maps export endpoints. /// public static void MapExportEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v1/timeline") .WithTags("Export") .RequireAuthorization(TimelinePolicies.Write) .RequireTenant(); group.MapPost("/{correlationId}/export", ExportTimelineAsync) .WithName("ExportTimeline") .WithDescription("Export timeline events as NDJSON bundle with optional DSSE signing"); group.MapGet("/export/{exportId}", GetExportStatusAsync) .WithName("GetExportStatus") .WithDescription("Get the status of an export operation"); group.MapGet("/export/{exportId}/download", DownloadExportAsync) .WithName("DownloadExport") .WithDescription("Download the completed export bundle"); } private static async Task, BadRequest>> ExportTimelineAsync( string correlationId, ExportRequest request, ITimelineQueryService queryService, ITimelineBundleBuilder bundleBuilder, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(correlationId)) { return TypedResults.BadRequest("Correlation ID is required"); } if (!IsSupportedFormat(request.Format)) { return TypedResults.BadRequest("Format must be either 'ndjson' or 'json'."); } if (!TryParseHlc(request.FromHlc, "fromHlc", out var fromHlc, out var fromParseError)) { return TypedResults.BadRequest(fromParseError); } if (!TryParseHlc(request.ToHlc, "toHlc", out var toHlc, out var toParseError)) { return TypedResults.BadRequest(toParseError); } // Validate the correlation exists var result = await queryService.GetByCorrelationIdAsync(correlationId, new TimelineQueryOptions { Limit = 1 }, cancellationToken); if (result.Events.Count == 0) { return TypedResults.BadRequest($"No events found for correlation ID: {correlationId}"); } var operation = await bundleBuilder.InitiateExportAsync( correlationId, new Core.Export.ExportRequest { Format = request.Format.ToLowerInvariant(), SignBundle = request.SignBundle, FromHlc = fromHlc, ToHlc = toHlc, IncludePayloads = true }, cancellationToken).ConfigureAwait(false); return TypedResults.Accepted( $"/api/v1/timeline/export/{operation.ExportId}", new ExportInitiatedResponse { ExportId = operation.ExportId, CorrelationId = operation.CorrelationId, Format = operation.Format, SignBundle = operation.SignBundle, Status = MapStatus(operation.Status), EstimatedEventCount = result.TotalCount }); } private static async Task, NotFound>> GetExportStatusAsync( string exportId, ITimelineBundleBuilder bundleBuilder, CancellationToken cancellationToken) { var operation = await bundleBuilder.GetExportStatusAsync(exportId, cancellationToken).ConfigureAwait(false); if (operation is null) { return TypedResults.NotFound(); } return TypedResults.Ok(new ExportStatusResponse { ExportId = operation.ExportId, Status = MapStatus(operation.Status), Format = operation.Format, EventCount = operation.EventCount, FileSizeBytes = operation.FileSizeBytes, CreatedAt = operation.CreatedAt, CompletedAt = operation.CompletedAt, Error = operation.Error }); } private static async Task> DownloadExportAsync( string exportId, ITimelineBundleBuilder bundleBuilder, CancellationToken cancellationToken) { var bundle = await bundleBuilder.GetExportBundleAsync(exportId, cancellationToken).ConfigureAwait(false); if (bundle is null) { return TypedResults.NotFound(); } return TypedResults.File( bundle.Content, contentType: bundle.ContentType, fileDownloadName: bundle.FileName); } private static bool IsSupportedFormat(string format) => string.Equals(format, "ndjson", StringComparison.OrdinalIgnoreCase) || string.Equals(format, "json", StringComparison.OrdinalIgnoreCase); private static bool TryParseHlc( string? rawValue, string parameterName, out HlcTimestamp? parsedValue, out string error) { parsedValue = null; error = string.Empty; if (string.IsNullOrWhiteSpace(rawValue)) { return true; } if (HlcTimestamp.TryParse(rawValue, out var hlc)) { parsedValue = hlc; return true; } error = $"Invalid {parameterName} value '{rawValue}'. Expected format '{{physicalTime13}}-{{nodeId}}-{{counter6}}'."; return false; } private static string MapStatus(ExportStatus status) => status switch { ExportStatus.Initiated => "INITIATED", ExportStatus.InProgress => "IN_PROGRESS", ExportStatus.Completed => "COMPLETED", ExportStatus.Failed => "FAILED", _ => "UNKNOWN" }; } // DTOs public sealed record ExportRequest { /// /// Export format: "ndjson" or "json". /// public string Format { get; init; } = "ndjson"; /// /// Whether to DSSE-sign the bundle. /// public bool SignBundle { get; init; } = false; /// /// Optional HLC range start. /// public string? FromHlc { get; init; } /// /// Optional HLC range end. /// public string? ToHlc { get; init; } } public sealed record ExportInitiatedResponse { public required string ExportId { get; init; } public required string CorrelationId { get; init; } public required string Format { get; init; } public bool SignBundle { get; init; } public required string Status { get; init; } public long EstimatedEventCount { get; init; } } public sealed record ExportStatusResponse { public required string ExportId { get; init; } public required string Status { get; init; } public required string Format { get; init; } public long EventCount { get; init; } public long FileSizeBytes { get; init; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? CompletedAt { get; init; } public string? Error { get; init; } }