Publish curated Knowledge Hub source from 2ed833f290b439a7bc91e5fd19b042c269e3bee9
This commit is contained in:
1 parent
10f2edcd29
commit
59f15b2d08
218 files changed
+23995
-1849
No files matched your search
@@ -0,0 +1,277 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ItWorx.KnowledgeHub.Api.Authorization;
|
||||
using ItWorx.KnowledgeHub.Api.Security;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Jobs;
|
||||
using ItWorx.KnowledgeHub.Application.Jobs.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Errors;
|
||||
using ItWorx.KnowledgeHub.Contracts.Jobs;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Jobs;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
||||
|
||||
/// <summary>Cookie-admin-only control surface for durable jobs. Mutations require CSRF;
|
||||
/// event streams expose only the latest persisted safe job snapshot.</summary>
|
||||
public static class JobsEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapJobsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobs")
|
||||
.WithTags("Jobs")
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
||||
|
||||
group.MapPost("/course-tagging", QueueCourseTaggingAsync)
|
||||
.RequireRateLimiting(RateLimitPolicies.JobCreation)
|
||||
.AddEndpointFilter<StrictAntiforgeryEndpointFilter>();
|
||||
group.MapPost("/backups", QueueBackupAsync)
|
||||
.RequireRateLimiting(RateLimitPolicies.JobCreation)
|
||||
.AddEndpointFilter<StrictAntiforgeryEndpointFilter>();
|
||||
group.MapGet("", ListAsync);
|
||||
group.MapGet("/{jobId:guid}", GetAsync);
|
||||
group.MapPost("/{jobId:guid}/cancel", CancelAsync)
|
||||
.AddEndpointFilter<StrictAntiforgeryEndpointFilter>();
|
||||
group.MapGet("/{jobId:guid}/events", EventsAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> QueueCourseTaggingAsync(
|
||||
[FromBody] QueueCourseTaggingJobRequest request,
|
||||
QueueBackgroundJobUseCase useCase,
|
||||
IWorkspaceRepository workspaces,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"]))
|
||||
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht.");
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound);
|
||||
|
||||
EnqueueJobResult result;
|
||||
try
|
||||
{
|
||||
result = await useCase.QueueCourseTaggingAsync(
|
||||
new QueueCourseTaggingJobCommand(
|
||||
workspaceId.Value, CurrentUserId(context.User), request.RootPageId, request.Tag,
|
||||
context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "De cursustagtaak is ongeldig.");
|
||||
}
|
||||
return ToEnqueueResult(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> QueueBackupAsync(
|
||||
QueueBackupJobRequest _,
|
||||
QueueBackgroundJobUseCase useCase,
|
||||
IWorkspaceRepository workspaces,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"]))
|
||||
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht.");
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound);
|
||||
EnqueueJobResult result;
|
||||
try
|
||||
{
|
||||
result = await useCase.QueueBackupAsync(
|
||||
new QueueJobCommand(workspaceId.Value, CurrentUserId(context.User),
|
||||
context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)), cancellationToken);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Een geldige Idempotency-Key is verplicht.");
|
||||
}
|
||||
return ToEnqueueResult(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<BackgroundJobListResponse>, ProblemHttpResult>> ListAsync(
|
||||
BackgroundJobQueries queries,
|
||||
IWorkspaceRepository workspaces,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
return TypedResults.Problem(statusCode: 404, type: ProblemTypes.NotFound);
|
||||
var items = await queries.ListAsync(workspaceId.Value, limit ?? 50, cancellationToken);
|
||||
return TypedResults.Ok(new BackgroundJobListResponse(items.Select(ToResponse).ToList()));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<BackgroundJobResponse>, NotFound>> GetAsync(
|
||||
Guid jobId,
|
||||
BackgroundJobQueries queries,
|
||||
IWorkspaceRepository workspaces,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
return TypedResults.NotFound();
|
||||
var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken);
|
||||
return job is null ? TypedResults.NotFound() : TypedResults.Ok(ToResponse(job));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelAsync(
|
||||
Guid jobId,
|
||||
CancelBackgroundJobUseCase useCase,
|
||||
IWorkspaceRepository workspaces,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
return Results.NotFound();
|
||||
var (status, job) = await useCase.ExecuteAsync(
|
||||
workspaceId.Value, jobId, CurrentUserId(context.User), CorrelationId(context), cancellationToken);
|
||||
if (status == CancelBackgroundJobStatus.NotFound || job is null)
|
||||
return Results.NotFound();
|
||||
return Results.Ok(ToResponse(job));
|
||||
}
|
||||
|
||||
private static async Task<IResult> EventsAsync(
|
||||
Guid jobId,
|
||||
BackgroundJobQueries queries,
|
||||
IWorkspaceRepository workspaces,
|
||||
IUserAccountManager users,
|
||||
IWorkspaceMembershipRepository memberships,
|
||||
BackgroundJobsOptions options,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
||||
if (workspaceId is null)
|
||||
return Results.NotFound();
|
||||
var initial = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken);
|
||||
if (initial is null)
|
||||
return Results.NotFound();
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = "text/event-stream; charset=utf-8";
|
||||
context.Response.Headers["Cache-Control"] = "no-cache, no-transform";
|
||||
context.Response.Headers.Append("X-Accel-Buffering", "no");
|
||||
var actorId = CurrentUserId(context.User);
|
||||
var lastVersion = -1L;
|
||||
var lastHeartbeat = timeProvider.GetUtcNow();
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var actor = await users.FindByIdAsync(actorId, cancellationToken);
|
||||
var role = actor is { Enabled: true }
|
||||
? await memberships.GetRoleAsync(workspaceId.Value, actorId, cancellationToken)
|
||||
: null;
|
||||
if (role != WorkspaceRole.Administrator)
|
||||
break;
|
||||
|
||||
var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken);
|
||||
if (job is null)
|
||||
break;
|
||||
if (job.IsTerminal)
|
||||
{
|
||||
// Terminal state must close every stream, even if a concurrent
|
||||
// worker update left the terminal snapshot at the last event ID.
|
||||
await WriteEventAsync(context, "terminal", job, cancellationToken);
|
||||
break;
|
||||
}
|
||||
if (job.Version != lastVersion)
|
||||
{
|
||||
var eventName = lastVersion < 0 ? "job" : "progress";
|
||||
await WriteEventAsync(context, eventName, job, cancellationToken);
|
||||
lastVersion = job.Version;
|
||||
lastHeartbeat = timeProvider.GetUtcNow();
|
||||
}
|
||||
else if (timeProvider.GetUtcNow() - lastHeartbeat >= TimeSpan.FromSeconds(options.SseHeartbeatSeconds))
|
||||
{
|
||||
await context.Response.WriteAsync(": heartbeat\n\n", cancellationToken);
|
||||
await context.Response.Body.FlushAsync(cancellationToken);
|
||||
lastHeartbeat = timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(options.SsePollMilliseconds), timeProvider, cancellationToken);
|
||||
}
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
private static async Task WriteEventAsync(HttpContext context, string eventName, BackgroundJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = JsonSerializer.Serialize(ToResponse(job), JsonOptions);
|
||||
await context.Response.WriteAsync($"id: {job.Version}\nevent: {eventName}\ndata: {data}\n\n", cancellationToken);
|
||||
await context.Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static IResult ToEnqueueResult(EnqueueJobResult result) => result.Status switch
|
||||
{
|
||||
EnqueueJobStatus.Created or EnqueueJobStatus.Existing when result.Job is not null =>
|
||||
Results.Accepted($"/api/v1/jobs/{result.Job.Id:D}", ToResponse(result.Job)),
|
||||
EnqueueJobStatus.IdempotencyConflict => Results.Problem(
|
||||
statusCode: 409, type: ProblemTypes.IdempotencyKeyConflict,
|
||||
title: "Idempotency-Key is al gebruikt voor een andere aanvraag."),
|
||||
EnqueueJobStatus.RootNotFound => Results.Problem(statusCode: 404, type: ProblemTypes.PageNotFound, title: "Hoofdpagina niet gevonden."),
|
||||
EnqueueJobStatus.TooManyPages => Results.Problem(
|
||||
statusCode: 413, type: ProblemTypes.Validation,
|
||||
title: "De cursuspaginaboom overschrijdt de ingestelde taaklimiet."),
|
||||
EnqueueJobStatus.QueueFull => Results.Problem(
|
||||
statusCode: 429, title: "De taaklimiet is bereikt. Wacht tot lopende taken klaar zijn of het dagbudget is hersteld."),
|
||||
_ => Results.Problem(statusCode: 500, title: "De taak kon niet worden ingepland.")
|
||||
};
|
||||
|
||||
private static BackgroundJobResponse ToResponse(BackgroundJob job)
|
||||
{
|
||||
var errors = Array.Empty<BackgroundJobErrorResponse>();
|
||||
try
|
||||
{
|
||||
errors = (JsonSerializer.Deserialize<BackgroundJobError[]>(job.ErrorsJson) ?? [])
|
||||
.Select(error => new BackgroundJobErrorResponse(error.Code, error.Message, error.ItemId))
|
||||
.ToArray();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A damaged error summary must not turn the read API into a 500 or expose raw storage.
|
||||
}
|
||||
JsonElement? result = null;
|
||||
if (!string.IsNullOrWhiteSpace(job.ResultJson))
|
||||
{
|
||||
try { result = JsonDocument.Parse(job.ResultJson).RootElement.Clone(); }
|
||||
catch (JsonException) { result = null; }
|
||||
}
|
||||
|
||||
return new BackgroundJobResponse(
|
||||
job.Id, job.WorkspaceId, job.ActorId,
|
||||
job.Type switch { BackgroundJobType.CourseTagging => "course-tagging", BackgroundJobType.Backup => "backup", _ => "unknown" },
|
||||
ToStatus(job.Status), job.CreatedAt, job.StartedAt, job.CompletedAt,
|
||||
job.CancelRequestedAt, job.CancelRequestedAt is not null,
|
||||
job.Progress, job.Total, job.Processed, job.Succeeded, job.Skipped, job.Failed,
|
||||
errors, result, job.CorrelationId, job.Version);
|
||||
}
|
||||
|
||||
private static string ToStatus(BackgroundJobStatus status) => status switch
|
||||
{
|
||||
BackgroundJobStatus.Queued => "queued",
|
||||
BackgroundJobStatus.Running => "running",
|
||||
BackgroundJobStatus.Succeeded => "succeeded",
|
||||
BackgroundJobStatus.PartiallySucceeded => "partially_succeeded",
|
||||
BackgroundJobStatus.Failed => "failed",
|
||||
BackgroundJobStatus.Cancelled => "cancelled",
|
||||
_ => "failed"
|
||||
};
|
||||
|
||||
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
||||
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
private static string? CorrelationId(HttpContext context) =>
|
||||
context.Items[CorrelationIdMiddleware.ItemKey] as string;
|
||||
}
|
||||
Reference in new issue
Block a user