Closes #149. **Outcome:** approving a registration now writes the canonical register record to the **Objecten** API as a `RegisterRecord` object, alongside the ZGW eindstatus. OpenZaak holds the process, Objecten holds the register (ADR-0028). The write goes through the ACL (§8.1) and is idempotent on the zaak id, so a replayed approval updates the existing object rather than creating a second one. S-19 (#20) was split first (CLAUDE.md §13) — it bundled this with re-sourcing the read projection, which is now #150. ### What landed - `IRegisterRecordGateway` + `RegisterRecord` in `Acl.Application`; `ObjectenGateway` in `Acl.Infrastructure` (static Token auth, CRS headers, objecttype resolved by name to its highest **published** version). - `AclService.ApproveZaakAsync` writes the record after the eindstatus, keyed on the zaak UUID with the zaak's identificatie as reference. - Compose wiring for both stacks; `ADR-0028`; demo note; PRD §15 out-of-scope line retired. ### Three things only a live stack found Running the gateway against a real Objecten + Objecttypen pair while writing this turned up blockers CI would have hit after the fact: 1. **Objecten rejects an objecttype it has not been configured with**, by UUID — assigned at seed time by a one-shot that runs *after* Objecten's static setup_configuration. The UUID is now pinned on both sides. 2. **Objecten 500s on every write when its Notificaties config is absent** (`notifications_api_common` raises rather than skipping). Objecten → NRC has no broker, worker, kanaal or abonnement, so notifications are **disabled** rather than wired to drop every message; #150 turns them on for real. 3. **Objecttypen echoes the request Host into the objecttype `url`**, and Objecten only accepts the one matching its configured `api_root` — so the ACL must read Objecttypen at `http://objecttypen:8000`. This is why the new integration test only passes inside the compose network. All three are recorded in ADR-0028. ### Verification - `ObjectenGatewayIntegrationTests` (verify-acl, in-network): two writes for one id leave exactly one object with the second write's status. **Passing locally against live Objecten.** - The **Playwright happy path** asserts, after the behandelaar approves, that Objecten holds exactly one `RegisterRecord` for *that* reference — missing, duplicated, or non-public-safe all fail. - ACL mutation score **92.23%** (baseline 91.37%, break 90). - `make lint` / `make unit` green locally; full-stack `make verify` runs in CI. ## Definition of Done - [x] A linked Gitea issue exists (#149). - [x] Failing test written and committed first. - [x] Implementation makes the test pass. - [x] Refactor commit follows if structure improved. - [x] Conventional Commit messages referencing the issue (`refs #149`). - [x] All Gitea Actions CI jobs green (run 684). - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (verify-stack step 1). - [x] Docs touched — ADR-0028, demo note, PRD §15, BACKLOG. - [x] ADR added: `docs/architecture/adr-0028-objecten-holds-the-register.md`. - [x] Demo note appended to `docs/demo-script.md`. - [x] Closed by the merging PR (`closes #149`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Acl.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a
|
||||
/// static <c>Authorization: Token …</c> (they are not ZGW JWT APIs), so there is no client-id/secret
|
||||
/// pair as with OpenZaak.
|
||||
/// </summary>
|
||||
public sealed class ObjectenOptions
|
||||
{
|
||||
public required Uri BaseUrl { get; init; }
|
||||
public required string Token { get; init; }
|
||||
|
||||
/// <summary>Objecttypen API root — the ACL resolves the objecttype URL + version from it by name
|
||||
/// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021).</summary>
|
||||
public required Uri ObjecttypenBaseUrl { get; init; }
|
||||
public required string ObjecttypenToken { get; init; }
|
||||
|
||||
/// <summary>The objecttype the register record is written as (S-18c registers "RegisterRecord").</summary>
|
||||
public required string ObjecttypeName { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user