Files
register-referentie/services/acl/Acl.Infrastructure/ObjectenGateway.cs
T
not 06c0444859 test(acl): submit writes an INGEDIEND record, and records are readable back (refs #153)
Ports and failing tests for the ACL half of S-19b-2, ahead of the implementation.

Once the projection is sourced from Objecten (ADR-0028's stated direction), a submitted
registration has to exist in the register the moment the zaak is opened — otherwise
re-sourcing silently drops every INGEDIEND row, since today only approval writes a record.
So `OpenZaakAsync` gains a second write, and approval upserts that same record to
INGESCHREVEN.

The subscriber gets only an object URL on an `objecten` notification (the payload carries no
record data) and may not read Objecten itself (§8.1), so `IRegisterRecordGateway` gains a
read and `AclService` exposes it.

Red:
- AclService does not yet write on open → the record assertion fails on an empty list.
- ObjectenGateway.GetAsync is a shell throwing NotImplementedException; its tests pin the
  contract: fetch the object URL directly (no objecttype resolution, no search), the CRS
  header a geo API requires, static Token auth, and a 404 read as "nothing to project"
  rather than an error (§8.6).
2026-08-28 12:28:01 +02:00

164 lines
8.0 KiB
C#

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Acl.Application;
namespace Acl.Infrastructure;
/// <summary>
/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object
/// of the <c>RegisterRecord</c> objecttype registered in S-18c.
/// </summary>
public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
{
// The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by
// name on first use rather than pinned in config (same reasoning as ADR-0021).
// ponytail: memoised per instance only — the gateway is a transient typed client, so in practice
// that is one extra GET per approval against a neighbouring container. Lift it into a singleton
// cache (as CachedZaaktypeCatalog does for ZGW) if approvals ever get hot.
private Objecttype? objecttype;
public async Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(record);
var type = objecttype ??= await ResolveObjecttypeAsync(ct);
var existing = await FindExistingAsync(type.Url, record.Id, ct);
var data = new RecordDataDto(record.Id, record.Status, record.Reference);
// No existing object → create; otherwise PATCH, which appends a new record version to the same
// object. Either way the register ends up with exactly one object per registration (§8.6).
if (existing is null)
await SendAsync(HttpMethod.Post, new Uri(options.BaseUrl, "/api/v2/objects"),
new CreateObjectDto(type.Url.ToString(), NewRecord(type.Version, data)),
"Creating the register record", ct);
else
await SendAsync(HttpMethod.Patch, existing,
new PatchObjectDto(NewRecord(type.Version, data)),
"Updating the register record", ct);
}
public Task<RegisterRecord?> GetAsync(Uri objectUrl, CancellationToken ct = default)
=> throw new NotImplementedException();
private RecordDto NewRecord(int typeVersion, RecordDataDto data) =>
new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd"));
/// <summary>The URL + latest published version of the configured objecttype, read from Objecttypen.</summary>
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
{
var page = await GetAsync<ObjecttypePage>(
new Uri(options.ObjecttypenBaseUrl, "/api/v2/objecttypes"),
options.ObjecttypenToken, crs: false, "objecttypen", ct);
var match = (page.Results ?? []).FirstOrDefault(o => o.Name == options.ObjecttypeName)
?? throw new InvalidOperationException(
$"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?");
// Write against the highest *published* version: a draft version's schema is still being
// shaped, and objects written against it would be validated by a moving target. The objecttype
// carries its versions as URLs, so each is fetched for its status (the collection response
// gives no status) — once per gateway instance, alongside the lookup above.
var latest = 0;
foreach (var versionUrl in match.Versions ?? [])
{
var version = await GetAsync<ObjecttypeVersionDto>(
new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct);
if (version.Status == "published" && version.Version > latest)
latest = version.Version;
}
if (latest == 0)
throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version");
return new Objecttype(new Uri(match.Url), latest);
}
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
private async Task<Uri?> FindExistingAsync(Uri objecttypeUrl, string id, CancellationToken ct)
{
var query = new Uri(options.BaseUrl,
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttypeUrl.ToString()) +
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
var page = await GetAsync<ObjectPage>(query, options.Token, crs: true, "objects", ct);
var match = (page.Results ?? []).FirstOrDefault();
return match is null ? null : new Uri(match.Url);
}
private async Task<T> GetAsync<T>(Uri uri, string token, bool crs, string label, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Get, uri);
message.Headers.Authorization = new AuthenticationHeaderValue("Token", token);
if (crs)
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, $"Querying {label}", ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException($"Objecten returned an empty {label} response");
}
private async Task SendAsync(HttpMethod method, Uri uri, object dto, string action, CancellationToken ct)
{
using var message = new HttpRequestMessage(method, uri) { Content = JsonContent.Create(dto) };
message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token);
// The Objecten API is a geo API: it requires the CRS headers on reads and writes alike.
message.Headers.Add("Accept-Crs", "EPSG:4326");
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
// As with OpenZaak, Objecten runs behind uwsgi, which rejects a chunked request body.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, action, ct);
}
// As in OpenZaakGateway: EnsureSuccessStatusCode discards the body, and the JSON validation error
// Objecten returns on a schema mismatch is exactly what you need to diagnose a rejected write.
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
}
private sealed record Objecttype(Uri Url, int Version);
private sealed record ObjecttypePage(
[property: JsonPropertyName("results")] IReadOnlyList<ObjecttypeDto>? Results);
private sealed record ObjecttypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions);
private sealed record ObjecttypeVersionDto(
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("status")] string? Status);
private sealed record ObjectPage(
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
private sealed record ObjectDto(
[property: JsonPropertyName("url")] string Url);
private sealed record CreateObjectDto(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("record")] RecordDto Record);
private sealed record PatchObjectDto(
[property: JsonPropertyName("record")] RecordDto Record);
private sealed record RecordDto(
[property: JsonPropertyName("typeVersion")] int TypeVersion,
[property: JsonPropertyName("data")] RecordDataDto Data,
[property: JsonPropertyName("startAt")] string StartAt);
private sealed record RecordDataDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("reference")] string? Reference);
}