using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Acl.Application;
namespace Acl.Infrastructure;
/// The only code that talks to OpenZaak's Zaken API (ADR-0001).
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
{
public async Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
using var message = new HttpRequestMessage(
HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/zaken"))
{
Content = JsonContent.Create(new ZaakDto(
request.Bronorganisatie,
request.Zaaktype.ToString(),
request.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding)),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// ZRC is a geo API; it requires the CRS headers.
message.Headers.Add("Accept-Crs", "EPSG:4326");
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
// OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
// JsonContent streams without a known length (→ Transfer-Encoding: chunked),
// so buffer it first to send a Content-Length instead. Only a real OpenZaak
// surfaces this — a stubbed HttpMessageHandler accepts either framing.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
return new Uri(created.Url);
}
public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
using var message = new HttpRequestMessage(
HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen"))
{
Content = JsonContent.Create(new StatusDto(
zaakUrl.ToString(),
eindstatus.ToString(),
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the
// body so uwsgi gets a Content-Length instead of a chunked body.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
}
/// Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.
private async Task ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
{
var query = new Uri(options.BaseUrl,
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response");
var results = page.Results ?? [];
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
// highest volgnummer if the flag is absent.
var eindstatus = results.FirstOrDefault(s => s.IsEindstatus)
?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}");
return new Uri(eindstatus.Url);
}
private sealed record ZaakDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url);
private sealed record StatusDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype,
[property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet);
private sealed record StatustypePage(
[property: JsonPropertyName("results")] IReadOnlyList? Results);
private sealed record StatustypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer,
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
}