diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index dbbf4e5..9660876 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -338,6 +338,14 @@ services: Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma + # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a + # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves + # it by name — lazily, on the first approval, so no depends_on is needed here. + Acl__Objecten__BaseUrl: http://objecten:8000/ + Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} + Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ + Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} + Acl__Objecten__ObjecttypeName: RegisterRecord ports: - "8100:8080" volumes: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index c836501..903cd3f 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -323,6 +323,14 @@ services: # so verify-domain still points the ACL at OpenZaak's container IP. Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma + # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a + # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves + # it by name — lazily, on the first approval, so no depends_on is needed here. + Acl__Objecten__BaseUrl: http://objecten:8000/ + Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} + Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ + Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} + Acl__Objecten__ObjecttypeName: RegisterRecord ports: - "8100:8080" healthcheck: diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 9b1b7bc..72ca25c 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -42,7 +42,12 @@ builder.Services.AddSingleton(sp => return new InMemoryDefaultFillStore( new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); }); +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("Acl:Objecten").Get() + ?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'")); builder.Services.AddHttpClient(); +// The Objecten hop that writes the register record on approval (S-19a, ADR-0028). +builder.Services.AddHttpClient(); // Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27). builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 37f2950..cab5577 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -28,16 +28,33 @@ public sealed class AclService( } /// - /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27). - /// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). + /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27), + /// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the + /// ACL owns which statustype means "approved" and what the register record looks like (§8.1). /// + /// + /// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes + /// across two modules and is eventually consistent by construction. Both are idempotent — a status + /// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed + /// approval converges rather than duplicating. + /// public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); + + await register.UpsertAsync( + new RegisterRecord( + ZaakId(zaakUrl), + RegisterRecordStatus.Ingeschreven, + await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)), + ct); } + /// The zaak's UUID — the key the register record and the read projection rows share. + private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/'); + /// /// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation /// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs index 50fc445..722f62c 100644 --- a/services/acl/Acl.Infrastructure/ObjectenGateway.cs +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -1,10 +1,143 @@ +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 the Objecten API (ADR-0028). +/// +/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object +/// of the RegisterRecord objecttype registered in S-18c. +/// public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway { - public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) => - throw new NotImplementedException(); + // 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")); + + /// The URL + latest version number of the configured objecttype, read from Objecttypen. + private async Task ResolveObjecttypeAsync(CancellationToken ct) + { + var page = await GetAsync( + 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?"); + + // `versions` lists the objecttype's version URLs; the count is the latest version number. + var version = match.Versions?.Count + ?? throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); + return new Objecttype(new Uri(match.Url), version); + } + + /// The URL of the object already holding this registration's record, or null if there is none. + private async Task 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(query, options.Token, crs: true, "objects", ct); + var match = (page.Results ?? []).FirstOrDefault(); + return match is null ? null : new Uri(match.Url); + } + + private async Task GetAsync(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(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? Results); + + private sealed record ObjecttypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("versions")] IReadOnlyList? Versions); + + private sealed record ObjectPage( + [property: JsonPropertyName("results")] IReadOnlyList? 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); }