using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Big.Application; using Big.Domain; namespace Big.Infrastructure; /// /// The Workflow Client — the only code that talks to Flowable (§8.2). It starts registratie process /// instances and drives the OpenZaakAanmaken external-worker jobs over Flowable's REST API /// (start: service/runtime/process-instances; acquire/complete: external-job-api/…). /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) : IWorkflowClient, IExternalWorkerClient, IUserTaskClient { private const string Topic = "OpenZaakAanmaken"; private const string ProcessDefinitionKey = "registratie"; private const string BeoordelenTaskKey = "Beoordelen"; private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; private const string BesluitVariable = "besluit"; public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { var request = new StartProcessRequest( ProcessDefinitionKey, [new Variable(RegistrationIdVariable, "string", registrationId.ToString())]); var created = await PostAsync( "service/runtime/process-instances", request, ct) ?? throw new InvalidOperationException("Flowable returned an empty process-instance response."); return created.Id; } public async Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default) { var request = new AcquireJobsRequest(Topic, options.LockDuration, maxJobs, options.WorkerId); var jobs = await PostAsync>( "external-job-api/acquire/jobs", request, ct) ?? []; return [.. jobs.Select(job => new OpenZaakJob(job.Id, RegistrationId.Parse(job.RegistrationId())))]; } public async Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default) { var request = new CompleteJobRequest( options.WorkerId, [new Variable(ZaakUrlVariable, "string", zaakUrl.ToString())]); using var response = await SendAsync( $"external-job-api/acquire/jobs/{jobId}/complete", request, ct); response.EnsureSuccessStatusCode(); } public async Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) { var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true); var page = await PostAsync( "service/query/tasks", request, ct); var tasks = page?.Data ?? []; return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))]; } public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) { using var response = await SendAsync( $"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct); response.EnsureSuccessStatusCode(); } public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default) { var request = new CompleteTaskRequest( "complete", [new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]); using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct); response.EnsureSuccessStatusCode(); } private async Task PostAsync(string path, TRequest body, CancellationToken ct) { using var response = await SendAsync(path, body, ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(ct); } private Task SendAsync(string path, TRequest body, CancellationToken ct) { var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) { Content = JsonContent.Create(body), }; message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials()); return http.SendAsync(message, ct); } private string BasicCredentials() => Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}")); private sealed record StartProcessRequest( [property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey, [property: JsonPropertyName("variables")] IReadOnlyList Variables); private sealed record AcquireJobsRequest( [property: JsonPropertyName("topic")] string Topic, [property: JsonPropertyName("lockDuration")] string LockDuration, [property: JsonPropertyName("numberOfTasks")] int NumberOfTasks, [property: JsonPropertyName("workerId")] string WorkerId); private sealed record CompleteJobRequest( [property: JsonPropertyName("workerId")] string WorkerId, [property: JsonPropertyName("variables")] IReadOnlyList Variables); private sealed record TaskQueryRequest( [property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey, [property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey, [property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables); private sealed record ClaimTaskRequest( [property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("assignee")] string Assignee); private sealed record CompleteTaskRequest( [property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("variables")] IReadOnlyList Variables); private sealed record TaskQueryResult( [property: JsonPropertyName("data")] IReadOnlyList? Data); private sealed record UserTaskDto( [property: JsonPropertyName("id")] string Id, // Flowable's task-query returns the (included) process variables under "variables", not // "processVariables"; the request opts in via includeProcessVariables. [property: JsonPropertyName("variables")] IReadOnlyList? Variables) { /// The registration id this task carries as a process variable. public string RegistrationId() => Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value ?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable."); } private sealed record Variable( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("value")] string Value); private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); private sealed record AcquiredJob( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("variables")] IReadOnlyList Variables) { /// The registration id this job carries as a process variable. public string RegistrationId() => // Stryker disable once Linq : equivalent — Single and SingleOrDefault both throw on a job // with no registrationId variable (the only untested branch), so the mutant is indistinguishable. Variables.SingleOrDefault(v => v.Name == "registrationId")?.Value ?? throw new InvalidOperationException($"OpenZaakAanmaken job {Id} carries no registrationId variable."); } }