refactor(acl): resolve the objecttype's highest published version (refs #149)
Counting the `versions` URLs assumed a contiguous, all-published list. Read the objecttype's versions collection instead and take the highest one whose status is `published`, so a draft version — whose schema is still being shaped — is never written against.
This commit is contained in:
@@ -41,7 +41,7 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC
|
||||
private RecordDto NewRecord(int typeVersion, RecordDataDto data) =>
|
||||
new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd"));
|
||||
|
||||
/// <summary>The URL + latest version number of the configured objecttype, read from Objecttypen.</summary>
|
||||
/// <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>(
|
||||
@@ -52,10 +52,16 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC
|
||||
?? 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);
|
||||
var url = new Uri(match.Url);
|
||||
// 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.
|
||||
var versions = await GetAsync<IReadOnlyList<ObjecttypeVersionDto>>(
|
||||
new Uri(url + "/versions"), options.ObjecttypenToken, crs: false, "objecttype versions", ct);
|
||||
var latest = versions.Where(v => v.Status == "published").Select(v => v.Version).DefaultIfEmpty(0).Max();
|
||||
if (latest == 0)
|
||||
throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version");
|
||||
|
||||
return new Objecttype(url, latest);
|
||||
}
|
||||
|
||||
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
|
||||
@@ -115,8 +121,11 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC
|
||||
|
||||
private sealed record ObjecttypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions);
|
||||
[property: JsonPropertyName("name")] string? Name);
|
||||
|
||||
private sealed record ObjecttypeVersionDto(
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("status")] string? Status);
|
||||
|
||||
private sealed record ObjectPage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
|
||||
|
||||
@@ -46,24 +46,32 @@ public class ObjectenGatewayTests
|
||||
},
|
||||
new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
// The Objecttypen lookup (by name) and the Objecten search (by data attribute) that precede every
|
||||
// write. `respondToWrite` decides what the POST/PATCH returns.
|
||||
// A stack that answers the three reads every write is preceded by: the objecttype list (matched by
|
||||
// name), that objecttype's versions, and the Objecten search for an existing record.
|
||||
private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
|
||||
req.RequestUri!.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
|
||||
? new HttpResponseMessage(HttpStatusCode.OK)
|
||||
req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal)
|
||||
? Json(new object[]
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
new { version = 1, status = "published" },
|
||||
new { version = 2, status = "published" },
|
||||
// A draft must never be written against, even though it is the highest version.
|
||||
new { version = 3, status = "draft" },
|
||||
})
|
||||
: req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
|
||||
? Json(new
|
||||
{
|
||||
results = new[]
|
||||
{
|
||||
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = new[] { "…/versions/1" } },
|
||||
new { url = ObjecttypeUrl, name = "RegisterRecord", versions = new[] { "…/versions/1", "…/versions/2" } },
|
||||
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse" },
|
||||
new { url = ObjecttypeUrl, name = "RegisterRecord" },
|
||||
},
|
||||
}),
|
||||
}
|
||||
: req.Method == HttpMethod.Get
|
||||
? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = existingObjects }) }
|
||||
: new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
|
||||
})
|
||||
: req.Method == HttpMethod.Get
|
||||
? Json(new { results = existingObjects })
|
||||
: new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
|
||||
|
||||
private static HttpResponseMessage Json(object body) =>
|
||||
new(HttpStatusCode.OK) { Content = JsonContent.Create(body) };
|
||||
|
||||
private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001");
|
||||
|
||||
@@ -76,6 +84,7 @@ public class ObjectenGatewayTests
|
||||
|
||||
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||
Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body);
|
||||
// The highest *published* version (2), not the highest version (a draft 3).
|
||||
Assert.Contains("\"typeVersion\":2", write.Body);
|
||||
Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
|
||||
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
|
||||
@@ -145,7 +154,19 @@ public class ObjectenGatewayTests
|
||||
await gateway.UpsertAsync(Record());
|
||||
await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" });
|
||||
|
||||
Assert.Single(sent, s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal));
|
||||
Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_loudly_when_the_objecttype_has_no_published_version()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal)
|
||||
? Json(new object[] { new { version = 1, status = "draft" } })
|
||||
: Route(req, []));
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("published version", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user