288 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			288 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.Collections.Immutable;
 | 
						|
using System.Globalization;
 | 
						|
using Microsoft.AspNetCore.Http;
 | 
						|
using Microsoft.AspNetCore.Routing;
 | 
						|
using StellaOps.Excititor.WebService.Services;
 | 
						|
 | 
						|
namespace StellaOps.Excititor.WebService.Endpoints;
 | 
						|
 | 
						|
internal static class IngestEndpoints
 | 
						|
{
 | 
						|
    private const string AdminScope = "vex.admin";
 | 
						|
 | 
						|
    public static void MapIngestEndpoints(IEndpointRouteBuilder app)
 | 
						|
    {
 | 
						|
        var group = app.MapGroup("/excititor");
 | 
						|
 | 
						|
        group.MapPost("/init", HandleInitAsync);
 | 
						|
        group.MapPost("/ingest/run", HandleRunAsync);
 | 
						|
        group.MapPost("/ingest/resume", HandleResumeAsync);
 | 
						|
        group.MapPost("/reconcile", HandleReconcileAsync);
 | 
						|
    }
 | 
						|
 | 
						|
    internal static async Task<IResult> HandleInitAsync(
 | 
						|
        HttpContext httpContext,
 | 
						|
        ExcititorInitRequest request,
 | 
						|
        IVexIngestOrchestrator orchestrator,
 | 
						|
        TimeProvider timeProvider,
 | 
						|
        CancellationToken cancellationToken)
 | 
						|
    {
 | 
						|
        var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
 | 
						|
        if (scopeResult is not null)
 | 
						|
        {
 | 
						|
            return scopeResult;
 | 
						|
        }
 | 
						|
 | 
						|
        var providerIds = NormalizeProviders(request.Providers);
 | 
						|
        _ = timeProvider;
 | 
						|
        var options = new IngestInitOptions(providerIds, request.Resume ?? false);
 | 
						|
 | 
						|
        var summary = await orchestrator.InitializeAsync(options, cancellationToken).ConfigureAwait(false);
 | 
						|
        var message = $"Initialized {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
 | 
						|
 | 
						|
        return TypedResults.Ok<object>(new
 | 
						|
        {
 | 
						|
            message,
 | 
						|
            runId = summary.RunId,
 | 
						|
            startedAt = summary.StartedAt,
 | 
						|
            completedAt = summary.CompletedAt,
 | 
						|
            providers = summary.Providers.Select(static provider => new
 | 
						|
            {
 | 
						|
                providerId = provider.ProviderId,
 | 
						|
                displayName = provider.DisplayName,
 | 
						|
                status = provider.Status,
 | 
						|
                durationMs = provider.Duration.TotalMilliseconds,
 | 
						|
                error = provider.Error
 | 
						|
            })
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    internal static async Task<IResult> HandleRunAsync(
 | 
						|
        HttpContext httpContext,
 | 
						|
        ExcititorIngestRunRequest request,
 | 
						|
        IVexIngestOrchestrator orchestrator,
 | 
						|
        TimeProvider timeProvider,
 | 
						|
        CancellationToken cancellationToken)
 | 
						|
    {
 | 
						|
        var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
 | 
						|
        if (scopeResult is not null)
 | 
						|
        {
 | 
						|
            return scopeResult;
 | 
						|
        }
 | 
						|
 | 
						|
        if (!TryParseDateTimeOffset(request.Since, out var since, out var sinceError))
 | 
						|
        {
 | 
						|
            return TypedResults.BadRequest<object>(new { message = sinceError });
 | 
						|
        }
 | 
						|
 | 
						|
        if (!TryParseTimeSpan(request.Window, out var window, out var windowError))
 | 
						|
        {
 | 
						|
            return TypedResults.BadRequest<object>(new { message = windowError });
 | 
						|
        }
 | 
						|
 | 
						|
        _ = timeProvider;
 | 
						|
        var providerIds = NormalizeProviders(request.Providers);
 | 
						|
        var options = new IngestRunOptions(
 | 
						|
            providerIds,
 | 
						|
            since,
 | 
						|
            window,
 | 
						|
            request.Force ?? false);
 | 
						|
 | 
						|
        var summary = await orchestrator.RunAsync(options, cancellationToken).ConfigureAwait(false);
 | 
						|
        var message = $"Ingest run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
 | 
						|
 | 
						|
        return TypedResults.Ok<object>(new
 | 
						|
        {
 | 
						|
            message,
 | 
						|
            runId = summary.RunId,
 | 
						|
            startedAt = summary.StartedAt,
 | 
						|
            completedAt = summary.CompletedAt,
 | 
						|
            durationMs = summary.Duration.TotalMilliseconds,
 | 
						|
            providers = summary.Providers.Select(static provider => new
 | 
						|
            {
 | 
						|
                providerId = provider.ProviderId,
 | 
						|
                status = provider.Status,
 | 
						|
                documents = provider.Documents,
 | 
						|
                claims = provider.Claims,
 | 
						|
                startedAt = provider.StartedAt,
 | 
						|
                completedAt = provider.CompletedAt,
 | 
						|
                durationMs = provider.Duration.TotalMilliseconds,
 | 
						|
                lastDigest = provider.LastDigest,
 | 
						|
                lastUpdated = provider.LastUpdated,
 | 
						|
                checkpoint = provider.Checkpoint,
 | 
						|
                error = provider.Error
 | 
						|
            })
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    internal static async Task<IResult> HandleResumeAsync(
 | 
						|
        HttpContext httpContext,
 | 
						|
        ExcititorIngestResumeRequest request,
 | 
						|
        IVexIngestOrchestrator orchestrator,
 | 
						|
        TimeProvider timeProvider,
 | 
						|
        CancellationToken cancellationToken)
 | 
						|
    {
 | 
						|
        var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
 | 
						|
        if (scopeResult is not null)
 | 
						|
        {
 | 
						|
            return scopeResult;
 | 
						|
        }
 | 
						|
 | 
						|
        _ = timeProvider;
 | 
						|
        var providerIds = NormalizeProviders(request.Providers);
 | 
						|
        var options = new IngestResumeOptions(providerIds, request.Checkpoint);
 | 
						|
 | 
						|
        var summary = await orchestrator.ResumeAsync(options, cancellationToken).ConfigureAwait(false);
 | 
						|
        var message = $"Resume run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
 | 
						|
 | 
						|
        return TypedResults.Ok<object>(new
 | 
						|
        {
 | 
						|
            message,
 | 
						|
            runId = summary.RunId,
 | 
						|
            startedAt = summary.StartedAt,
 | 
						|
            completedAt = summary.CompletedAt,
 | 
						|
            durationMs = summary.Duration.TotalMilliseconds,
 | 
						|
            providers = summary.Providers.Select(static provider => new
 | 
						|
            {
 | 
						|
                providerId = provider.ProviderId,
 | 
						|
                status = provider.Status,
 | 
						|
                documents = provider.Documents,
 | 
						|
                claims = provider.Claims,
 | 
						|
                startedAt = provider.StartedAt,
 | 
						|
                completedAt = provider.CompletedAt,
 | 
						|
                durationMs = provider.Duration.TotalMilliseconds,
 | 
						|
                since = provider.Since,
 | 
						|
                checkpoint = provider.Checkpoint,
 | 
						|
                error = provider.Error
 | 
						|
            })
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    internal static async Task<IResult> HandleReconcileAsync(
 | 
						|
        HttpContext httpContext,
 | 
						|
        ExcititorReconcileRequest request,
 | 
						|
        IVexIngestOrchestrator orchestrator,
 | 
						|
        TimeProvider timeProvider,
 | 
						|
        CancellationToken cancellationToken)
 | 
						|
    {
 | 
						|
        var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
 | 
						|
        if (scopeResult is not null)
 | 
						|
        {
 | 
						|
            return scopeResult;
 | 
						|
        }
 | 
						|
 | 
						|
        if (!TryParseTimeSpan(request.MaxAge, out var maxAge, out var error))
 | 
						|
        {
 | 
						|
            return TypedResults.BadRequest<object>(new { message = error });
 | 
						|
        }
 | 
						|
 | 
						|
        _ = timeProvider;
 | 
						|
        var providerIds = NormalizeProviders(request.Providers);
 | 
						|
        var options = new ReconcileOptions(providerIds, maxAge);
 | 
						|
 | 
						|
        var summary = await orchestrator.ReconcileAsync(options, cancellationToken).ConfigureAwait(false);
 | 
						|
        var message = $"Reconcile completed for {summary.ProviderCount} provider(s); {summary.ReconciledCount} reconciled, {summary.SkippedCount} skipped, {summary.FailureCount} failed.";
 | 
						|
 | 
						|
        return TypedResults.Ok<object>(new
 | 
						|
        {
 | 
						|
            message,
 | 
						|
            runId = summary.RunId,
 | 
						|
            startedAt = summary.StartedAt,
 | 
						|
            completedAt = summary.CompletedAt,
 | 
						|
            durationMs = summary.Duration.TotalMilliseconds,
 | 
						|
            providers = summary.Providers.Select(static provider => new
 | 
						|
            {
 | 
						|
                providerId = provider.ProviderId,
 | 
						|
                status = provider.Status,
 | 
						|
                action = provider.Action,
 | 
						|
                lastUpdated = provider.LastUpdated,
 | 
						|
                threshold = provider.Threshold,
 | 
						|
                documents = provider.Documents,
 | 
						|
                claims = provider.Claims,
 | 
						|
                error = provider.Error
 | 
						|
            })
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    internal static ImmutableArray<string> NormalizeProviders(IReadOnlyCollection<string>? providers)
 | 
						|
    {
 | 
						|
        if (providers is null || providers.Count == 0)
 | 
						|
        {
 | 
						|
            return ImmutableArray<string>.Empty;
 | 
						|
        }
 | 
						|
 | 
						|
        var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
 | 
						|
        foreach (var provider in providers)
 | 
						|
        {
 | 
						|
            if (string.IsNullOrWhiteSpace(provider))
 | 
						|
            {
 | 
						|
                continue;
 | 
						|
            }
 | 
						|
 | 
						|
            set.Add(provider.Trim());
 | 
						|
        }
 | 
						|
 | 
						|
        return set.ToImmutableArray();
 | 
						|
    }
 | 
						|
 | 
						|
    internal static bool TryParseDateTimeOffset(string? value, out DateTimeOffset? result, out string? error)
 | 
						|
    {
 | 
						|
        result = null;
 | 
						|
        error = null;
 | 
						|
 | 
						|
        if (string.IsNullOrWhiteSpace(value))
 | 
						|
        {
 | 
						|
            return true;
 | 
						|
        }
 | 
						|
 | 
						|
        if (DateTimeOffset.TryParse(
 | 
						|
            value.Trim(),
 | 
						|
            CultureInfo.InvariantCulture,
 | 
						|
            DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
 | 
						|
            out var parsed))
 | 
						|
        {
 | 
						|
            result = parsed;
 | 
						|
            return true;
 | 
						|
        }
 | 
						|
 | 
						|
        error = "Invalid 'since' value. Use ISO-8601 format (e.g. 2025-10-19T12:30:00Z).";
 | 
						|
        return false;
 | 
						|
    }
 | 
						|
 | 
						|
    internal static bool TryParseTimeSpan(string? value, out TimeSpan? result, out string? error)
 | 
						|
    {
 | 
						|
        result = null;
 | 
						|
        error = null;
 | 
						|
 | 
						|
        if (string.IsNullOrWhiteSpace(value))
 | 
						|
        {
 | 
						|
            return true;
 | 
						|
        }
 | 
						|
 | 
						|
        if (TimeSpan.TryParse(value.Trim(), CultureInfo.InvariantCulture, out var parsed) && parsed >= TimeSpan.Zero)
 | 
						|
        {
 | 
						|
            result = parsed;
 | 
						|
            return true;
 | 
						|
        }
 | 
						|
 | 
						|
        error = "Invalid duration value. Use TimeSpan format (e.g. 1.00:00:00).";
 | 
						|
        return false;
 | 
						|
    }
 | 
						|
 | 
						|
    internal sealed record ExcititorInitRequest(IReadOnlyList<string>? Providers, bool? Resume);
 | 
						|
 | 
						|
    internal sealed record ExcititorIngestRunRequest(
 | 
						|
        IReadOnlyList<string>? Providers,
 | 
						|
        string? Since,
 | 
						|
        string? Window,
 | 
						|
        bool? Force);
 | 
						|
 | 
						|
    internal sealed record ExcititorIngestResumeRequest(
 | 
						|
        IReadOnlyList<string>? Providers,
 | 
						|
        string? Checkpoint);
 | 
						|
 | 
						|
    internal sealed record ExcititorReconcileRequest(
 | 
						|
        IReadOnlyList<string>? Providers,
 | 
						|
        string? MaxAge);
 | 
						|
}
 |