Files
register-referentie/services/acl/Acl.Infrastructure/ObjectenGateway.cs
T
not 566ef7dd64 feat(acl): write the INGEDIEND record on submit and read records back (refs #153)
- OpenZaakAsync upserts a RegisterRecord with status INGEDIEND after opening the zaak,
  keyed on the same zaak id approval later upserts to INGESCHREVEN. The reference comes
  from the registration, so this path needs no ZGW read-back.
- ObjectenGateway.GetAsync fetches an object by the URL a notification carried — no
  objecttype resolution, no search — and reads 404 as "no record" rather than an error.
- POST /register-records/read exposes it to the Event Subscriber, which may not talk to
  Objecten itself (§8.1).
2026-08-28 12:28:47 +02:00

192 lines
9.4 KiB
C#

using System.Net;
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 async Task<RegisterRecord?> GetAsync(Uri objectUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(objectUrl);
// Fetched by the URL the notification carried, so no objecttype resolution and no search —
// unlike a write, which has to find the object for a registration id.
using var message = new HttpRequestMessage(HttpMethod.Get, objectUrl);
message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token);
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
// The object may be gone by the time a (possibly redelivered) notification is handled —
// there is simply nothing to project, which is not a failure (§8.6).
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccessAsync(response, "Reading the register record", ct);
var body = await response.Content.ReadFromJsonAsync<ReadObjectDto>(ct)
?? throw new InvalidOperationException("Objecten returned an empty object response");
var data = body.Record?.Data;
return data is null ? null : new RegisterRecord(data.Id, data.Status, data.Reference);
}
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 ReadObjectDto(
[property: JsonPropertyName("record")] ReadRecordDto? Record);
private sealed record ReadRecordDto(
[property: JsonPropertyName("data")] RecordDataDto? Data);
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);
}