- Implemented BunPackageNormalizer to deduplicate packages by name and version. - Created BunProjectDiscoverer to identify Bun project roots in the filesystem. - Added project files for the Bun analyzer including manifest and project configuration. - Developed comprehensive tests for Bun language analyzer covering various scenarios. - Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces. - Established stubs for authentication sessions to facilitate testing in the web application.
239 lines
8.0 KiB
C#
239 lines
8.0 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Policy.Engine.ConsoleExport;
|
|
|
|
namespace StellaOps.Policy.Engine.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Endpoints for Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
|
|
/// </summary>
|
|
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<IResult> 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<string, object?> { ["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<string, object?> { ["code"] = code });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> 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<IResult> 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<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
|
}
|
|
|
|
return Results.Ok(job);
|
|
}
|
|
|
|
private static async Task<IResult> 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<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return Results.Problem(
|
|
title: "Validation failed",
|
|
detail: ex.Message,
|
|
statusCode: 400,
|
|
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.InvalidSchedule });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> DeleteJobAsync(
|
|
[FromRoute] string jobId,
|
|
ConsoleExportJobService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await service.DeleteJobAsync(jobId, cancellationToken).ConfigureAwait(false);
|
|
return Results.NoContent();
|
|
}
|
|
|
|
private static async Task<IResult> 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<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> 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<IResult> 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<IResult> 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);
|
|
}
|
|
}
|