Some checks failed
CI / lint (pull_request) Successful in 1m17s
CI / build (pull_request) Successful in 1m14s
CI / unit (pull_request) Successful in 1m17s
CI / frontend (pull_request) Successful in 2m41s
CI / mutation (pull_request) Successful in 5m9s
CI / verify-stack (pull_request) Failing after 7m53s
The task-query endpoint is service/query/tasks; the wrong path 404'd, so verify-domain's werkbak poll got an empty body and the JSON parser aborted the check. Correct the path in the Workflow Client (and its unit test) and make the live-check parser tolerant of a transient empty/non-JSON body so the poll retries instead of crashing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
7.7 KiB
C#
167 lines
7.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The Workflow Client — the only code that talks to Flowable (§8.2). It starts registratie process
|
|
/// instances and drives the <c>OpenZaakAanmaken</c> external-worker jobs over Flowable's REST API
|
|
/// (start: <c>service/runtime/process-instances</c>; acquire/complete: <c>external-job-api/…</c>).
|
|
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
|
/// </summary>
|
|
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<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
|
{
|
|
var request = new StartProcessRequest(
|
|
ProcessDefinitionKey,
|
|
[new Variable(RegistrationIdVariable, "string", registrationId.ToString())]);
|
|
|
|
var created = await PostAsync<StartProcessRequest, ProcessInstance>(
|
|
"service/runtime/process-instances", request, ct)
|
|
?? throw new InvalidOperationException("Flowable returned an empty process-instance response.");
|
|
return created.Id;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<OpenZaakJob>> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default)
|
|
{
|
|
var request = new AcquireJobsRequest(Topic, options.LockDuration, maxJobs, options.WorkerId);
|
|
|
|
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredJob>>(
|
|
"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<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
|
{
|
|
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
|
|
|
|
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
|
|
"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<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
|
{
|
|
using var response = await SendAsync(path, body, ct);
|
|
response.EnsureSuccessStatusCode();
|
|
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
|
}
|
|
|
|
private Task<HttpResponseMessage> SendAsync<TRequest>(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<Variable> 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<Variable> 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<Variable> Variables);
|
|
|
|
private sealed record TaskQueryResult(
|
|
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
|
|
|
|
private sealed record UserTaskDto(
|
|
[property: JsonPropertyName("id")] string Id,
|
|
[property: JsonPropertyName("processVariables")] IReadOnlyList<Variable>? ProcessVariables)
|
|
{
|
|
/// <summary>The registration id this task carries as a process variable.</summary>
|
|
public string RegistrationId() =>
|
|
ProcessVariables?.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<Variable> Variables)
|
|
{
|
|
/// <summary>The registration id this job carries as a process variable.</summary>
|
|
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.");
|
|
}
|
|
}
|