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) =>
|
private RecordDto NewRecord(int typeVersion, RecordDataDto data) =>
|
||||||
new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd"));
|
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)
|
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var page = await GetAsync<ObjecttypePage>(
|
var page = await GetAsync<ObjecttypePage>(
|
||||||
@@ -52,10 +52,16 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?");
|
$"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 url = new Uri(match.Url);
|
||||||
var version = match.Versions?.Count
|
// Write against the highest *published* version: a draft version's schema is still being
|
||||||
?? throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version");
|
// shaped, and objects written against it would be validated by a moving target.
|
||||||
return new Objecttype(new Uri(match.Url), version);
|
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>
|
/// <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(
|
private sealed record ObjecttypeDto(
|
||||||
[property: JsonPropertyName("url")] string Url,
|
[property: JsonPropertyName("url")] string Url,
|
||||||
[property: JsonPropertyName("name")] string? Name,
|
[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(
|
private sealed record ObjectPage(
|
||||||
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
|
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
|
||||||
|
|||||||
@@ -46,25 +46,33 @@ public class ObjectenGatewayTests
|
|||||||
},
|
},
|
||||||
new FixedClock(new DateOnly(2026, 6, 4)));
|
new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
// The Objecttypen lookup (by name) and the Objecten search (by data attribute) that precede every
|
// A stack that answers the three reads every write is preceded by: the objecttype list (matched by
|
||||||
// write. `respondToWrite` decides what the POST/PATCH returns.
|
// name), that objecttype's versions, and the Objecten search for an existing record.
|
||||||
private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
|
private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
|
||||||
req.RequestUri!.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
|
req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal)
|
||||||
? new HttpResponseMessage(HttpStatusCode.OK)
|
? 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[]
|
results = new[]
|
||||||
{
|
{
|
||||||
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = new[] { "…/versions/1" } },
|
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse" },
|
||||||
new { url = ObjecttypeUrl, name = "RegisterRecord", versions = new[] { "…/versions/1", "…/versions/2" } },
|
new { url = ObjecttypeUrl, name = "RegisterRecord" },
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
}
|
|
||||||
: req.Method == HttpMethod.Get
|
: req.Method == HttpMethod.Get
|
||||||
? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = existingObjects }) }
|
? Json(new { results = existingObjects })
|
||||||
: new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
|
: 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");
|
private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -76,6 +84,7 @@ public class ObjectenGatewayTests
|
|||||||
|
|
||||||
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||||
Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body);
|
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("\"typeVersion\":2", write.Body);
|
||||||
Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
|
Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
|
||||||
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
|
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
|
||||||
@@ -145,7 +154,19 @@ public class ObjectenGatewayTests
|
|||||||
await gateway.UpsertAsync(Record());
|
await gateway.UpsertAsync(Record());
|
||||||
await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" });
|
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]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user