using Microsoft.AspNetCore.Mvc; using StellaOps.Policy.Engine.ConsoleExport; namespace StellaOps.Policy.Engine.Endpoints; /// /// Endpoints for Console export jobs per CONTRACT-EXPORT-BUNDLE-009. /// public static class ConsoleExportEndpoints { public static IEndpointRouteBuilder MapConsoleExportJobs(this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/api/v1/export"); // Job management group.MapPost("/jobs", CreateJobAsync) .WithName("Export.CreateJob") .WithDescription("Create a new export job"); group.MapGet("/jobs", ListJobsAsync) .WithName("Export.ListJobs") .WithDescription("List export jobs"); group.MapGet("/jobs/{jobId}", GetJobAsync) .WithName("Export.GetJob") .WithDescription("Get an export job by ID"); group.MapPut("/jobs/{jobId}", UpdateJobAsync) .WithName("Export.UpdateJob") .WithDescription("Update an export job"); group.MapDelete("/jobs/{jobId}", DeleteJobAsync) .WithName("Export.DeleteJob") .WithDescription("Delete an export job"); // Job execution group.MapPost("/jobs/{jobId}/run", TriggerJobAsync) .WithName("Export.TriggerJob") .WithDescription("Trigger a job execution"); group.MapGet("/jobs/{jobId}/executions/{executionId}", GetExecutionAsync) .WithName("Export.GetExecution") .WithDescription("Get execution status"); // Bundle retrieval group.MapGet("/bundles/{bundleId}", GetBundleAsync) .WithName("Export.GetBundle") .WithDescription("Get bundle manifest"); group.MapGet("/bundles/{bundleId}/download", DownloadBundleAsync) .WithName("Export.DownloadBundle") .WithDescription("Download bundle content"); return routes; } private static async Task CreateJobAsync( [FromHeader(Name = "X-Tenant-Id")] string? tenantId, [FromBody] CreateExportJobRequest request, ConsoleExportJobService service, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(tenantId)) { return Results.Problem( title: "Tenant ID required", detail: "X-Tenant-Id header is required", statusCode: 400, extensions: new Dictionary { ["code"] = "TENANT_REQUIRED" }); } try { var job = await service.CreateJobAsync(tenantId, request, cancellationToken).ConfigureAwait(false); return Results.Created($"/api/v1/export/jobs/{job.JobId}", job); } catch (ArgumentException ex) { var code = ex.Message.Contains("schedule", StringComparison.OrdinalIgnoreCase) ? ExportErrorCodes.InvalidSchedule : ExportErrorCodes.InvalidDestination; return Results.Problem( title: "Validation failed", detail: ex.Message, statusCode: 400, extensions: new Dictionary { ["code"] = code }); } } private static async Task ListJobsAsync( [FromQuery] string? tenant_id, ConsoleExportJobService service, CancellationToken cancellationToken) { var response = await service.ListJobsAsync(tenant_id, cancellationToken).ConfigureAwait(false); return Results.Ok(response); } private static async Task GetJobAsync( [FromRoute] string jobId, ConsoleExportJobService service, CancellationToken cancellationToken) { var job = await service.GetJobAsync(jobId, cancellationToken).ConfigureAwait(false); if (job is null) { return Results.Problem( title: "Job not found", detail: $"Job '{jobId}' not found", statusCode: 404, extensions: new Dictionary { ["code"] = ExportErrorCodes.JobNotFound }); } return Results.Ok(job); } private static async Task UpdateJobAsync( [FromRoute] string jobId, [FromBody] UpdateExportJobRequest request, ConsoleExportJobService service, CancellationToken cancellationToken) { try { var job = await service.UpdateJobAsync(jobId, request, cancellationToken).ConfigureAwait(false); return Results.Ok(job); } catch (KeyNotFoundException) { return Results.Problem( title: "Job not found", detail: $"Job '{jobId}' not found", statusCode: 404, extensions: new Dictionary { ["code"] = ExportErrorCodes.JobNotFound }); } catch (ArgumentException ex) { return Results.Problem( title: "Validation failed", detail: ex.Message, statusCode: 400, extensions: new Dictionary { ["code"] = ExportErrorCodes.InvalidSchedule }); } } private static async Task DeleteJobAsync( [FromRoute] string jobId, ConsoleExportJobService service, CancellationToken cancellationToken) { await service.DeleteJobAsync(jobId, cancellationToken).ConfigureAwait(false); return Results.NoContent(); } private static async Task TriggerJobAsync( [FromRoute] string jobId, ConsoleExportJobService service, CancellationToken cancellationToken) { try { var response = await service.TriggerJobAsync(jobId, cancellationToken).ConfigureAwait(false); return Results.Accepted($"/api/v1/export/jobs/{jobId}/executions/{response.ExecutionId}", response); } catch (KeyNotFoundException) { return Results.Problem( title: "Job not found", detail: $"Job '{jobId}' not found", statusCode: 404, extensions: new Dictionary { ["code"] = ExportErrorCodes.JobNotFound }); } } private static async Task GetExecutionAsync( [FromRoute] string jobId, [FromRoute] string executionId, ConsoleExportJobService service, CancellationToken cancellationToken) { var execution = await service.GetExecutionAsync(executionId, cancellationToken).ConfigureAwait(false); if (execution is null || !string.Equals(execution.JobId, jobId, StringComparison.Ordinal)) { return Results.NotFound(); } return Results.Ok(execution); } private static async Task GetBundleAsync( [FromRoute] string bundleId, ConsoleExportJobService service, CancellationToken cancellationToken) { var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false); if (bundle is null) { return Results.NotFound(); } return Results.Ok(bundle); } private static async Task DownloadBundleAsync( [FromRoute] string bundleId, ConsoleExportJobService service, CancellationToken cancellationToken) { var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false); if (bundle is null) { return Results.NotFound(); } var content = await service.GetBundleContentAsync(bundleId, cancellationToken).ConfigureAwait(false); if (content is null) { return Results.NotFound(); } var contentType = bundle.Format switch { ExportFormats.Ndjson => "application/x-ndjson", _ => "application/json" }; var fileName = $"export-{bundle.BundleId}-{DateTime.UtcNow:yyyy-MM-dd}.json"; return Results.File( content, contentType, fileName); } }