Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user