223 lines
7.3 KiB
C#
223 lines
7.3 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// Export endpoints for timeline bundles.
|
|
/// </summary>
|
|
public static class ExportEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps export endpoints.
|
|
/// </summary>
|
|
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<Results<Accepted<ExportInitiatedResponse>, BadRequest<string>>> 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<Results<Ok<ExportStatusResponse>, 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<Results<FileStreamHttpResult, NotFound>> 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
|
|
{
|
|
/// <summary>
|
|
/// Export format: "ndjson" or "json".
|
|
/// </summary>
|
|
public string Format { get; init; } = "ndjson";
|
|
|
|
/// <summary>
|
|
/// Whether to DSSE-sign the bundle.
|
|
/// </summary>
|
|
public bool SignBundle { get; init; } = false;
|
|
|
|
/// <summary>
|
|
/// Optional HLC range start.
|
|
/// </summary>
|
|
public string? FromHlc { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional HLC range end.
|
|
/// </summary>
|
|
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; }
|
|
}
|