using System.Net; 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). 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 { // 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 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(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")); /// The URL + latest published version 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?"); // 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( 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); } /// 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 ObjecttypeVersionDto( [property: JsonPropertyName("version")] int Version, [property: JsonPropertyName("status")] string? Status); private sealed record ObjectPage( [property: JsonPropertyName("results")] IReadOnlyList? 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); }