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 { private const string Topic = "OpenZaakAanmaken"; private const string ProcessDefinitionKey = "registratie"; private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; 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(); } 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 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."); } }