feat(zgw): OpenZaak Documenten (DRC) upload + zaak link (WP-51)

Extends the OpenZaak seam with IDocumentSource, sibling of IZaakSource
(WP-49/50): an upload always lands locally first (DocumentStore stays
the record of truth for preview/download/audit) and, when
Zgw:Enabled=true, is also registered as a DRC enkelvoudiginformatie-
object; once a zaak exists (IZaakSource.CreateZaak now also returns
its ZaakUrl), submit links each document to it via zaakinformatie-
object. FE upload/list DTOs are unchanged.

- ZgwOptions gains DrcBaseUrl + a category->informatieobjecttype URL
  map (the document analogue of ZaaktypeUrls).
- LocalDocumentSource is the same DocumentStore.Add/Link calls the
  endpoints used to make inline — zero behaviour change offline.
- OpenZaakDocumentSource POSTs the eio then the zaak link, persisting
  the DRC url (DocumentStore.SetDrcUrl) so linking doesn't re-upload.
- Factored the GET/POST-with-bearer-JWT plumbing shared with
  OpenZaakZaakSource into ZgwHttpClient; shared the stub handler
  between the two source test classes as ZgwStubHandler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-29 20:54:31 +02:00
co-authored by Claude Sonnet 5
parent 3671684528
commit 5807937229
18 changed files with 836 additions and 116 deletions
@@ -26,6 +26,13 @@ public sealed class Aanvraag
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
/// <summary>The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under
/// the local source. Persisted so later steps (WP-51's document→zaak link) can find it
/// without a network round-trip; IZaakSource.CreateZaak itself doesn't write here (the
/// endpoint does, via <see cref="ApplicationStore.SetZaakUrl"/>) to keep the seam's write
/// surface at "return data", not "reach into another store".</summary>
public string? ZaakUrl { get; set; }
} }
/// <summary> /// <summary>
@@ -172,4 +179,18 @@ public static class ApplicationStore
return a; return a;
} }
} }
/// <summary>Persist the zaak URL CreateZaak (WP-50) registered for this aanvraag. No-op if
/// the aanvraag is gone (shouldn't happen — this runs right after Submit found it).</summary>
public static void SetZaakUrl(string id, string zaakUrl)
{
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null) return;
a.ZaakUrl = zaakUrl;
db.SaveChanges();
}
}
} }
@@ -11,6 +11,12 @@ public sealed record StoredDocument(
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt) string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
{ {
public bool Linked { get; set; } public bool Linked { get; set; }
/// <summary>The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload (WP-51)
/// registers one — null under the local source. Persisted so the later zaak-link step can
/// find it without re-uploading; not part of the positional constructor, same reasoning as
/// <see cref="Linked"/> (every existing `new StoredDocument(...)` call site keeps working).</summary>
public string? DrcUrl { get; set; }
} }
/// <summary>Id is EF Core's auto-increment key — not part of the positional /// <summary>Id is EF Core's auto-increment key — not part of the positional
@@ -69,6 +75,19 @@ public static class DocumentStore
} }
} }
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl)
{
lock (_gate)
{
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null) return;
d.DrcUrl = drcUrl;
db.SaveChanges();
}
}
/// Mark digital documents as linked to a finalised submission (blocks user delete). /// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds) public static void Link(IEnumerable<string> documentIds)
{ {
@@ -0,0 +1,28 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// The documents seam (WP-51), sibling of <see cref="IZaakSource"/>: uploads always land
/// locally first (<see cref="DocumentStore"/> stays the record of truth for preview/download/
/// audit regardless of config, exactly like <c>ApplicationStore.Submit</c> for aanvragen,
/// WP-50) — this interface is only the OpenZaak integration side-effect, selected the same way
/// (<c>Zgw:Enabled</c>). Default binding is <see cref="LocalDocumentSource"/> (offline);
/// <c>OpenZaakDocumentSource</c> also registers each upload as a DRC
/// enkelvoudiginformatieobject and links it to a zaak once one exists.
/// </summary>
public interface IDocumentSource
{
/// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the
/// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.</summary>
UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner);
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
/// source additionally links each document (that has a DRC url) to the zaak, once
/// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in
/// which case there is nothing extra to link).</summary>
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl);
}
@@ -22,11 +22,13 @@ public interface IZaakSource
/// <summary> /// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is /// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the /// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the
/// integration side-effect, and its return value is what the submit endpoint hands back to /// integration side-effect, and (Referentie, Status) is what the submit endpoint hands back
/// the FE (ADR-0001: route the create through the existing submit response DTO, don't add a /// to the FE (ADR-0001: route the create through the existing submit response DTO, don't add
/// second one). The local source is a pure passthrough of the already-computed local /// a second one). The local source is a pure passthrough of the already-computed local
/// reference/status; the OpenZaak source creates a Zaak (+ status + rol) and maps the result /// reference/status (ZaakUrl null — nothing to persist); the OpenZaak source creates a Zaak
/// back into the same shape. /// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL
/// so the endpoint can persist it (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it
/// to later link documents to this zaak).
/// </summary> /// </summary>
(string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now); (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now);
} }
@@ -0,0 +1,23 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// The default <see cref="IDocumentSource"/> — uploads go only to the local SQLite
/// <see cref="DocumentStore"/>, exactly as before this seam existed (WP-51). Zero behaviour
/// change: this is the same <c>DocumentStore.Add</c>/<c>DocumentStore.Link</c> the upload/
/// submit endpoints used to call inline.
/// </summary>
public sealed class LocalDocumentSource : IDocumentSource
{
public UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner)
{
var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, owner);
return new UploadResponse(doc.DocumentId, doc.LocalId);
}
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl) =>
DocumentStore.Link(documentIds);
}
@@ -15,6 +15,6 @@ public sealed class LocalZaakSource : IZaakSource
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record /// <summary>No external zaak to create — the aanvraag's local submit already IS the record
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary> /// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
(aanvraag.Referentie!, aanvraag.ToStatusDto(now)); (aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
} }
@@ -0,0 +1,279 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260729071227_ZaakAndDrcUrls")]
partial class ZaakAndDrcUrls
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Decision")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuthzAudit");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER");
b.HasKey("Key");
b.ToTable("FeatureFlags");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ZaakAndDrcUrls : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DrcUrl",
table: "Documents",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ZaakUrl",
table: "Applications",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DrcUrl",
table: "Documents");
migrationBuilder.DropColumn(
name: "ZaakUrl",
table: "Applications");
}
}
}
@@ -64,6 +64,9 @@ namespace BigRegister.Api.Data.Migrations
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Applications"); b.ToTable("Applications");
@@ -235,6 +238,9 @@ namespace BigRegister.Api.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName") b.Property<string>("FileName")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+19 -8
View File
@@ -51,10 +51,13 @@ if (zgw.Enabled)
builder.Services.AddSingleton(zgw); builder.Services.AddSingleton(zgw);
builder.Services.AddSingleton<ZgwTokenProvider>(); builder.Services.AddSingleton<ZgwTokenProvider>();
builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(); builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>();
// WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above.
builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>();
} }
else else
{ {
builder.Services.AddSingleton<IZaakSource, LocalZaakSource>(); builder.Services.AddSingleton<IZaakSource, LocalZaakSource>();
builder.Services.AddSingleton<IDocumentSource, LocalDocumentSource>();
} }
var app = builder.Build(); var app = builder.Build();
@@ -177,7 +180,7 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded // Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type // from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
// and size authoritatively; stores metadata only (no file bytes / PII held). // and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request) => api.MapPost("/uploads", async (HttpRequest request, IDocumentSource documents) =>
{ {
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync(); var form = await request.ReadFormAsync();
@@ -192,8 +195,11 @@ api.MapPost("/uploads", async (HttpRequest request) =>
using var ms = new MemoryStream(); using var ms = new MemoryStream();
await file.CopyToAsync(ms); await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
// the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
}) })
.ExcludeFromDescription(); .ExcludeFromDescription();
@@ -299,7 +305,7 @@ api.MapDelete("/applications/{id}", (string id) =>
// Submit runs the server-owned rules, sets autoApprovable, and transitions the // Submit runs the server-owned rules, sets autoApprovable, and transitions the
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken) => api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) =>
{ {
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound(); if (existing is null) return Results.NotFound();
@@ -314,9 +320,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
}; };
var docs = req.Documents; var docs = req.Documents;
if (docs is not null) var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds); var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict(); if (submitted is null) return Results.Conflict();
@@ -329,7 +333,14 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
// of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak // of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak
// in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005: // in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005:
// zero FE contract change either way). // zero FE contract change either way).
var (referentie, status) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow); var (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow);
if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl);
// WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the
// DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally
// POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists.
if (documentIds is not null) documents.LinkToZaak(documentIds, zaakUrl);
return Results.Ok(new SubmitApplicationResponse(referentie, status)); return Results.Ok(new SubmitApplicationResponse(referentie, status));
}) })
.Produces<SubmitApplicationResponse>() .Produces<SubmitApplicationResponse>()
@@ -0,0 +1,103 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
namespace BigRegister.Api.Zgw;
/// <summary>
/// The <see cref="IDocumentSource"/> backed by a real OpenZaak / ZGW Documenten API (DRC,
/// WP-51). An upload always lands locally first (<see cref="DocumentStore"/> stays the record
/// of truth for preview/download/audit, same reasoning as <see cref="OpenZaakZaakSource"/>'s
/// dual-write for aanvragen, WP-50) and is then ALSO registered as a DRC
/// enkelvoudiginformatieobject, whose url is persisted (<see cref="DocumentStore.SetDrcUrl"/>)
/// so <see cref="LinkToZaak"/> can find it later without a re-upload. Selected only when
/// <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalDocumentSource"/>.
///
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>), same as
/// <see cref="OpenZaakZaakSource"/> — creating a document needs write scope on Documenten;
/// linking one to a zaak needs write scope on Zaken (the zaakinformatieobject resource).
/// </summary>
public sealed class OpenZaakDocumentSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IDocumentSource
{
private readonly ZgwHttpClient zgw = new(http, tokens);
// ponytail: sync-over-async — IDocumentSource is sync to match the local store + the
// existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource.
public UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner) =>
UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, owner)
.GetAwaiter().GetResult();
private async Task<UploadResponse> UploadAsync(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner)
{
var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, owner);
if (!options.InformatieobjecttypeUrls.TryGetValue(categoryId, out var informatieobjecttypeUrl))
throw new InvalidOperationException(
$"Zgw:InformatieobjecttypeUrls has no entry for category '{categoryId}'.");
var eio = await zgw.PostAsync<Eio>($"{options.DrcBaseUrl}/enkelvoudiginformatieobjecten", new CreateEioRequest(
Bronorganisatie: options.Bronorganisatie,
Creatiedatum: DateOnly.FromDateTime(doc.UploadedAt.UtcDateTime),
Titel: fileName,
Auteur: options.UserRepresentation,
Taal: "nld",
Formaat: contentType,
Bestandsnaam: fileName,
Inhoud: Convert.ToBase64String(content),
Informatieobjecttype: informatieobjecttypeUrl,
Identificatie: doc.DocumentId,
// ponytail: hardcoded "openbaar" (public) — real usage would likely vary the
// confidentiality level per category (e.g. an identity document is more sensitive
// than a diploma); a fixed value is enough to prove the seam end-to-end.
Vertrouwelijkheidaanduiding: "openbaar"));
DocumentStore.SetDrcUrl(doc.DocumentId, eio.Url);
return new UploadResponse(doc.DocumentId, doc.LocalId);
}
/// <summary>Local link always happens (dual-write, same reasoning as upload); additionally,
/// once a zaak exists, POST a zaakinformatieobject for every document that has a DRC url —
/// documents uploaded before Zgw:Enabled was ever true (or under a config gap) simply have
/// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case.</summary>
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl)
{
DocumentStore.Link(documentIds);
if (zaakUrl is null) return;
LinkToZaakAsync(documentIds, zaakUrl).GetAwaiter().GetResult();
}
private async Task LinkToZaakAsync(IReadOnlyList<string> documentIds, string zaakUrl)
{
foreach (var documentId in documentIds)
{
var drcUrl = DocumentStore.Get(documentId)?.DrcUrl;
if (drcUrl is null) continue;
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/zaakinformatieobjecten",
new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl));
}
}
private sealed record Eio([property: JsonPropertyName("url")] string Url);
private sealed record CreateEioRequest(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("creatiedatum")] DateOnly Creatiedatum,
[property: JsonPropertyName("titel")] string Titel,
[property: JsonPropertyName("auteur")] string Auteur,
[property: JsonPropertyName("taal")] string Taal,
[property: JsonPropertyName("formaat")] string Formaat,
[property: JsonPropertyName("bestandsnaam")] string Bestandsnaam,
[property: JsonPropertyName("inhoud")] string Inhoud,
[property: JsonPropertyName("informatieobjecttype")] string Informatieobjecttype,
[property: JsonPropertyName("identificatie")] string Identificatie,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
private sealed record CreateZaakInformatieobjectRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("informatieobject")] string Informatieobject);
}
@@ -1,5 +1,3 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
@@ -27,6 +25,8 @@ public sealed record ZgwPage<T>(
/// </summary> /// </summary>
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
{ {
private readonly ZgwHttpClient zgw = new(http, tokens);
// ponytail: sync-over-async — IZaakSource is sync to match the local store + the existing // ponytail: sync-over-async — IZaakSource is sync to match the local store + the existing
// sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the // sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the
// whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the
@@ -55,7 +55,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
string? next = url; string? next = url;
while (next is not null) while (next is not null)
{ {
var page = await GetAsync<ZgwPage<T>>(next); var page = await zgw.GetAsync<ZgwPage<T>>(next);
all.AddRange(page.Results); all.AddRange(page.Results);
next = page.Next; next = page.Next;
} }
@@ -65,21 +65,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary> /// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary>
private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl) private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl)
{ {
var zt = await GetAsync<Zaaktype>(zaaktypeUrl); var zt = await zgw.GetAsync<Zaaktype>(zaaktypeUrl);
return zt.Omschrijving; return zt.Omschrijving;
} }
private async Task<T> GetAsync<T>(string url)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint());
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
// --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------ // --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the /// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
@@ -91,16 +80,16 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak. /// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak.
/// Acceptable for a first write slice against a demo backend; a production arc would need a /// Acceptable for a first write slice against a demo backend; a production arc would need a
/// retry/reconciliation story (or an outbox) before this dual-write can be trusted. /// retry/reconciliation story (or an outbox) before this dual-write can be trusted.
public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult(); CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult();
private async Task<(string Referentie, AanvraagStatusDto Status)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now) private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now)
{ {
if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl)) if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
throw new InvalidOperationException( throw new InvalidOperationException(
$"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'."); $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
var zaak = await PostAsync<ZgwZaak>($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest( var zaak = await zgw.PostAsync<ZgwZaak>($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest(
Zaaktype: zaaktypeUrl, Zaaktype: zaaktypeUrl,
Bronorganisatie: options.Bronorganisatie, Bronorganisatie: options.Bronorganisatie,
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie, VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
@@ -109,18 +98,18 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first."))); ?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")));
var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl); var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl);
await PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen",
new CreateStatusRequest(zaak.Url, statustypeUrl, now)); new CreateStatusRequest(zaak.Url, statustypeUrl, now));
var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl); var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl);
await PostAsync<JsonElement>($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest( await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest(
Zaak: zaak.Url, Zaak: zaak.Url,
BetrokkeneType: "natuurlijk_persoon", BetrokkeneType: "natuurlijk_persoon",
Roltype: roltypeUrl, Roltype: roltypeUrl,
Roltoelichting: "Initiator", Roltoelichting: "Initiator",
BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner))); BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner)));
return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie)); return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie), zaak.Url);
} }
// ponytail: takes the first statustype (lowest volgnummer) / the first "initiator" roltype // ponytail: takes the first statustype (lowest volgnummer) / the first "initiator" roltype
@@ -129,7 +118,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
// initiator role (the normal case); add per-type config if that ever stops holding. // initiator role (the normal case); add per-type config if that ever stops holding.
private async Task<string> FirstStatustypeUrlAsync(string zaaktypeUrl) private async Task<string> FirstStatustypeUrlAsync(string zaaktypeUrl)
{ {
var page = await GetAsync<ZgwPage<Statustype>>( var page = await zgw.GetAsync<ZgwPage<Statustype>>(
$"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}"); $"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
var first = page.Results.OrderBy(s => s.Volgnummer).FirstOrDefault() var first = page.Results.OrderBy(s => s.Volgnummer).FirstOrDefault()
?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}."); ?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}.");
@@ -138,24 +127,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
private async Task<string> FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl) private async Task<string> FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl)
{ {
var page = await GetAsync<ZgwPage<Roltype>>( var page = await zgw.GetAsync<ZgwPage<Roltype>>(
$"{options.ZtcBaseUrl}/roltypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}&omschrijvingGeneriek=initiator"); $"{options.ZtcBaseUrl}/roltypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}&omschrijvingGeneriek=initiator");
var first = page.Results.FirstOrDefault() var first = page.Results.FirstOrDefault()
?? throw new InvalidOperationException($"No 'initiator' roltype found for zaaktype {zaaktypeUrl}."); ?? throw new InvalidOperationException($"No 'initiator' roltype found for zaaktype {zaaktypeUrl}.");
return first.Url; return first.Url;
} }
private async Task<T> PostAsync<T>(string url, object body)
{
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint());
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW POST {url} returned null body.");
}
private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving); private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
private sealed record Statustype( private sealed record Statustype(
@@ -0,0 +1,39 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace BigRegister.Api.Zgw;
/// <summary>
/// Shared GET/POST-with-Bearer-JWT plumbing for the ZGW source classes. Factored out of
/// <see cref="OpenZaakZaakSource"/> once <c>OpenZaakDocumentSource</c> (WP-51) needed the
/// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token
/// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON.
/// </summary>
internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{
public async Task<T> GetAsync<T>(string url)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
Authorize(req);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
public async Task<T> PostAsync<T>(string url, object body)
{
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
Authorize(req);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW POST {url} returned null body.");
}
private void Authorize(HttpRequestMessage req)
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint());
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
}
+12 -3
View File
@@ -6,9 +6,10 @@ namespace BigRegister.Api.Zgw;
/// SQLite store; set <c>Zgw:Enabled=true</c> (plus the URLs + credentials) to source cases /// SQLite store; set <c>Zgw:Enabled=true</c> (plus the URLs + credentials) to source cases
/// from a real OpenZaak. /// from a real OpenZaak.
/// ///
/// The ZGW standard is FIVE separate services, each its own base URL — Slice 1 only needs /// The ZGW standard is FIVE separate services, each its own base URL — slice 1 (WP-49) only
/// the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API (ZTC). /// needed the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API
/// The others (DRC/BRC/NRC) arrive with later slices (WP-51/52). /// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC).
/// BRC/NRC arrive with later slices (WP-52+).
/// </summary> /// </summary>
public sealed class ZgwOptions public sealed class ZgwOptions
{ {
@@ -43,4 +44,12 @@ public sealed class ZgwOptions
/// <summary>RSIN of the organisation responsible for the zaak (<c>verantwoordelijkeOrganisatie</c>, /// <summary>RSIN of the organisation responsible for the zaak (<c>verantwoordelijkeOrganisatie</c>,
/// WP-50) — usually the same RSIN as <see cref="Bronorganisatie"/>.</summary> /// WP-50) — usually the same RSIN as <see cref="Bronorganisatie"/>.</summary>
public string VerantwoordelijkeOrganisatie { get; init; } = ""; public string VerantwoordelijkeOrganisatie { get; init; } = "";
/// <summary>Documenten API (DRC) base URL, e.g. <c>https://open-zaak.example/documenten/api/v1</c> (WP-51).</summary>
public string DrcBaseUrl { get; init; } = "";
/// <summary>Upload <c>CategoryId</c> (diploma/identiteit/taalvaardigheid/...) → informatieobjecttype
/// URL (Catalogi), so create-document (WP-51) knows which type to register per category —
/// the document analogue of <see cref="ZaaktypeUrls"/>.</summary>
public Dictionary<string, string> InformatieobjecttypeUrls { get; init; } = new();
} }
@@ -0,0 +1,108 @@
using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
namespace BigRegister.Tests;
/// <summary>
/// Exercises the OpenZaak document source against a stub HttpMessageHandler (WP-51): an
/// upload registers a DRC enkelvoudiginformatieobject, and linking to a zaak POSTs a
/// zaakinformatieobject per document once a zaak URL is known.
/// </summary>
public class OpenZaakDocumentSourceTests
{
private const string DrcBase = "https://oz.example/documenten/api/v1";
private const string ZrcBase = "https://oz.example/zaken/api/v1";
private const string ZaaktypeUrl = "https://oz.example/catalogi/api/v1/zaaktypen/zt-registratie";
private const string InformatieobjecttypeUrl = "https://oz.example/catalogi/api/v1/informatieobjecttypen/iot-identiteit";
private static ZgwOptions Options() => new()
{
DrcBaseUrl = DrcBase,
ZrcBaseUrl = ZrcBase,
ClientId = "c",
Secret = "s",
Bronorganisatie = "123443210",
UserRepresentation = "BIG-register BFF",
InformatieobjecttypeUrls = new() { ["identiteit"] = InformatieobjecttypeUrl },
};
[Fact]
public void Upload_registers_an_eio_in_drc_and_persists_its_url_locally()
{
var options = Options();
var handler = new ZgwStubHandler(url => url switch
{
_ when url == $"{DrcBase}/enkelvoudiginformatieobjecten" =>
"""{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1" }""",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
});
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var response = source.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf",
"%PDF-1.4 fake"u8.ToArray(), "111222333");
Assert.Equal("local-1", response.LocalId);
Assert.NotEmpty(response.DocumentId);
// Registered locally too (dual-write, same reasoning as CreateZaak/WP-50) — content
// preview/download keeps working regardless of Zgw:Enabled.
var stored = DocumentStore.Get(response.DocumentId);
Assert.NotNull(stored);
Assert.Equal("https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1", stored!.DrcUrl);
var body = handler.BodyOf($"{DrcBase}/enkelvoudiginformatieobjecten");
Assert.Contains(InformatieobjecttypeUrl, body);
Assert.Contains("123443210", body); // bronorganisatie
Assert.Contains("paspoort.pdf", body);
Assert.Contains(Convert.ToBase64String("%PDF-1.4 fake"u8.ToArray()), body); // inhoud
}
[Fact]
public void Upload_throws_when_the_category_has_no_configured_informatieobjecttype()
{
var options = Options();
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws<InvalidOperationException>(() =>
source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], "111222333"));
}
[Fact]
public void LinkToZaak_posts_a_zaakinformatieobject_per_document_once_a_zaak_exists()
{
var options = Options();
var uploadHandler = new ZgwStubHandler(url =>
"""{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1" }""");
var uploader = new OpenZaakDocumentSource(new HttpClient(uploadHandler), new ZgwTokenProvider(options), options);
var doc = uploader.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf", [1, 2, 3], "111222333");
var linkHandler = new ZgwStubHandler(url => url switch
{
_ when url == $"{ZrcBase}/zaakinformatieobjecten" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
});
var linker = new OpenZaakDocumentSource(new HttpClient(linkHandler), new ZgwTokenProvider(options), options);
linker.LinkToZaak([doc.DocumentId], $"{ZrcBase}/zaken/uuid-1");
var body = linkHandler.BodyOf($"{ZrcBase}/zaakinformatieobjecten");
Assert.Contains($"{ZrcBase}/zaken/uuid-1", body);
Assert.Contains("eio-1", body);
// Local link also happened (dual-write) — the document is now Linked (delete blocked).
Assert.Equal(DocumentStore.DeleteResult.Linked, DocumentStore.DeleteOwned(doc.DocumentId, "111222333"));
}
[Fact]
public void LinkToZaak_makes_no_zgw_call_when_the_local_source_created_no_zaak()
{
var options = Options();
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
source.LinkToZaak(["some-document-id"], zaakUrl: null);
Assert.Empty(handler.Requests);
}
}
@@ -1,5 +1,3 @@
using System.Net;
using System.Text;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Api.Zgw; using BigRegister.Api.Zgw;
@@ -34,7 +32,7 @@ public class OpenZaakZaakSourceTests
[Fact] [Fact]
public void Follows_pagination_caches_zaaktype_and_sends_bearer_token() public void Follows_pagination_caches_zaaktype_and_sends_bearer_token()
{ {
var handler = new StubHandler(url => url switch var handler = new ZgwStubHandler(url => url switch
{ {
_ when url == $"{ZrcBase}/zaken" => Page1, _ when url == $"{ZrcBase}/zaken" => Page1,
_ when url == $"{ZrcBase}/zaken?page=2" => Page2, _ when url == $"{ZrcBase}/zaken?page=2" => Page2,
@@ -64,7 +62,7 @@ public class OpenZaakZaakSourceTests
public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back() public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back()
{ {
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie"; const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
var handler = new StubHandler(url => url switch var handler = new ZgwStubHandler(url => url switch
{ {
_ when url == $"{ZrcBase}/zaken" => $$""" _ when url == $"{ZrcBase}/zaken" => $$"""
{ "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123", { "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123",
@@ -103,27 +101,26 @@ public class OpenZaakZaakSourceTests
Referentie = "BIG-2026-000123", Referentie = "BIG-2026-000123",
}; };
var (referentie, status) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero));
Assert.Equal("BIG-2026-000123", referentie); Assert.Equal("BIG-2026-000123", referentie);
Assert.Equal("InBehandeling", status.Tag); Assert.Equal("InBehandeling", status.Tag);
Assert.Equal("BIG-2026-000123", status.Referentie); Assert.Equal("BIG-2026-000123", status.Referentie);
Assert.Equal($"{ZrcBase}/zaken/uuid-new", zaakUrl);
string BodyOf(string url) => handler.Bodies[handler.Requests.LastIndexOf(url)];
// Zaak: mapped zaaktype + configured RSINs + the local reference as identificatie. // Zaak: mapped zaaktype + configured RSINs + the local reference as identificatie.
var zaakBody = BodyOf($"{ZrcBase}/zaken"); var zaakBody = handler.BodyOf($"{ZrcBase}/zaken");
Assert.Contains(zaaktypeUrl, zaakBody); Assert.Contains(zaaktypeUrl, zaakBody);
Assert.Contains("123443210", zaakBody); Assert.Contains("123443210", zaakBody);
Assert.Contains("BIG-2026-000123", zaakBody); Assert.Contains("BIG-2026-000123", zaakBody);
// Status: points at the created zaak's URL and the resolved statustype. // Status: points at the created zaak's URL and the resolved statustype.
var statusBody = BodyOf($"{ZrcBase}/statussen"); var statusBody = handler.BodyOf($"{ZrcBase}/statussen");
Assert.Contains($"{ZrcBase}/zaken/uuid-new", statusBody); Assert.Contains($"{ZrcBase}/zaken/uuid-new", statusBody);
Assert.Contains("statustypen/st-1", statusBody); Assert.Contains("statustypen/st-1", statusBody);
// Rol: points at the created zaak, the resolved initiator roltype, and the BSN. // Rol: points at the created zaak, the resolved initiator roltype, and the BSN.
var rolBody = BodyOf($"{ZrcBase}/rollen"); var rolBody = handler.BodyOf($"{ZrcBase}/rollen");
Assert.Contains($"{ZrcBase}/zaken/uuid-new", rolBody); Assert.Contains($"{ZrcBase}/zaken/uuid-new", rolBody);
Assert.Contains("roltypen/rt-initiator", rolBody); Assert.Contains("roltypen/rt-initiator", rolBody);
Assert.Contains("111222333", rolBody); Assert.Contains("111222333", rolBody);
@@ -133,29 +130,10 @@ public class OpenZaakZaakSourceTests
public void CreateZaak_throws_when_the_aanvraag_type_has_no_configured_zaaktype() public void CreateZaak_throws_when_the_aanvraag_type_has_no_configured_zaaktype()
{ {
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var handler = new StubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" }; var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" };
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow)); Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow));
} }
private sealed class StubHandler(Func<string, string> respond) : HttpMessageHandler
{
public List<string> Requests { get; } = new();
public List<string?> AuthSchemes { get; } = new();
public List<string> Bodies { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
AuthSchemes.Add(request.Headers.Authorization?.Scheme);
Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? "");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(respond(url), Encoding.UTF8, "application/json"),
});
}
}
} }
@@ -0,0 +1,32 @@
using System.Net;
using System.Text;
namespace BigRegister.Tests;
/// <summary>
/// Stub HttpMessageHandler shared by the ZGW source tests (no live server, no mocking
/// library) — keyed purely by request URL (method-agnostic, since no test scenario reuses a
/// URL across GET/POST). Records every request's url/body/auth-scheme for assertion.
/// Factored out of OpenZaakZaakSourceTests once OpenZaakDocumentSourceTests needed the
/// identical stub.
/// </summary>
internal sealed class ZgwStubHandler(Func<string, string> respond) : HttpMessageHandler
{
public List<string> Requests { get; } = new();
public List<string?> AuthSchemes { get; } = new();
public List<string> Bodies { get; } = new();
public string BodyOf(string url) => Bodies[Requests.LastIndexOf(url)];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
AuthSchemes.Add(request.Headers.Authorization?.Scheme);
Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? "");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(respond(url), Encoding.UTF8, "application/json"),
});
}
}
+79 -33
View File
@@ -1,10 +1,12 @@
# OpenZaak / ZGW integration — how the BFF connects (& how to extend) # OpenZaak / ZGW integration — how the BFF connects (& how to extend)
How the BFF sources (and now creates) cases against a real **OpenZaak** (ZGW APIs) while the How the BFF sources (and now creates) cases, and uploads/links documents, against a real
frontend stays unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); **OpenZaak** (ZGW APIs) while the frontend stays unchanged. For the _why_, see
this page is _how the seam is built and how to add the next slice_. Built in [ADR-0005](architecture/0005-openzaak-behind-bff.md); this page is _how the seam is built and
[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken) and how to add the next slice_. Built in
[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (the first write: create-zaak). [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken),
[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (create-zaak), and
[WP-51](../project/backlog/WP-51-openzaak-documenten.md) (Documenten/DRC upload + zaak link).
## The one rule: OpenZaak sits behind the BFF, never in the browser ## The one rule: OpenZaak sits behind the BFF, never in the browser
@@ -17,15 +19,21 @@ with **zero frontend change and no api-client drift**.
- `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and - `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and
`CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping. `CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping.
`CreateZaak` also returns the zaak's URL (`ZaakUrl`, null under the local source) so WP-51
can later link documents to it.
- `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore` - `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore`
(offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit (offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit
endpoint already computed locally — no external call. endpoint already computed locally — no external call.
- `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`. - `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`.
`CreateZaak` posts a Zaak, then a Status, then a Rol (see below). `CreateZaak` posts a Zaak, then a Status, then a Rol (see below).
- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient<IZaakSource, OpenZaakZaakSource>() - `Data/IDocumentSource.cs` — the documents seam (WP-51), sibling of `IZaakSource`: `Upload`
else AddSingleton<IZaakSource, LocalZaakSource>()`. The `/admin/cases` GET and the and `LinkToZaak`. `Data/LocalDocumentSource.cs` is the same `DocumentStore.Add`/`Link` calls
`/applications/{id}/submit` POST both resolve `IZaakSource` from DI — routes + DTOs the upload/submit endpoints used to make inline; `Zgw/OpenZaakDocumentSource.cs` also
untouched either way. registers each upload as a DRC document and links it to a zaak once one exists.
- Wiring (`Program.cs`): `if (Zgw:Enabled)` registers `OpenZaakZaakSource` +
`OpenZaakDocumentSource`, else `LocalZaakSource` + `LocalDocumentSource`. The `/admin/cases`
GET, the `/uploads` POST, and the `/applications/{id}/submit` POST all resolve their seam
from DI — routes + DTOs untouched either way.
## Create-zaak (WP-50) — the first write ## Create-zaak (WP-50) — the first write
@@ -33,9 +41,10 @@ else AddSingleton<IZaakSource, LocalZaakSource>()`. The `/admin/cases` GET and t
— unconditionally, regardless of `Zgw:Enabled`, since draft/step/document bookkeeping stays — unconditionally, regardless of `Zgw:Enabled`, since draft/step/document bookkeeping stays
local either way) and only THEN calls `zaken.CreateZaak(submitted, now)`. The submit endpoint local either way) and only THEN calls `zaken.CreateZaak(submitted, now)`. The submit endpoint
never branches on `Zgw:Enabled` itself — DI already picked the implementation, so the endpoint never branches on `Zgw:Enabled` itself — DI already picked the implementation, so the endpoint
just asks the seam for `(Referentie, Status)` and returns exactly that in the unchanged just asks the seam for `(Referentie, Status, ZaakUrl)` and returns the first two, unchanged, in
`SubmitApplicationResponse`. Under the default (local) source this returns precisely what was `SubmitApplicationResponse` (`ZaakUrl` is persisted via `ApplicationStore.SetZaakUrl` for
just computed; under OpenZaak, three calls happen in order: WP-51's document link, not returned to the FE). Under the default (local) source this returns
precisely what was just computed; under OpenZaak, three calls happen in order:
1. **POST zaak** (`{ZrcBaseUrl}/zaken`) — `zaaktype` resolved from `Zgw:ZaaktypeUrls[aanvraag.Type]` 1. **POST zaak** (`{ZrcBaseUrl}/zaken`) — `zaaktype` resolved from `Zgw:ZaaktypeUrls[aanvraag.Type]`
(OpenZaak validates the URL by fetching it), `bronorganisatie`/`verantwoordelijkeOrganisatie` (OpenZaak validates the URL by fetching it), `bronorganisatie`/`verantwoordelijkeOrganisatie`
@@ -58,19 +67,47 @@ status and initiator role; (b) no compensating transaction — if any ZGW call t
aanvraag is already `Submitted` locally with no matching zaak (acceptable for a demo backend; aanvraag is already `Submitted` locally with no matching zaak (acceptable for a demo backend;
a production arc needs retry/reconciliation or an outbox before trusting this dual-write). a production arc needs retry/reconciliation or an outbox before trusting this dual-write).
## Documenten / DRC upload + zaak link (WP-51)
`POST /uploads` and `POST /applications/{id}/submit` route through `IDocumentSource` the same
way submit routes through `IZaakSource`: the local write (`DocumentStore.Add`/`Link`) always
happens first — it stays the record of truth for preview/download/audit regardless of
`Zgw:Enabled` — and `OpenZaakDocumentSource` additionally does the DRC side-effect:
1. **Upload** — POST `enkelvoudiginformatieobjecten` (`{DrcBaseUrl}`) with the file's base64
content, `informatieobjecttype` resolved from `Zgw:InformatieobjecttypeUrls[categoryId]`
(the document analogue of `ZaaktypeUrls`), `identificatie` set to the local document id. The
returned DRC url is persisted (`DocumentStore.SetDrcUrl`) so the link step below doesn't
need to re-upload.
2. **Link to zaak** — once `IZaakSource.CreateZaak` has returned a `ZaakUrl` (persisted via
`ApplicationStore.SetZaakUrl`), submit calls `documents.LinkToZaak(documentIds, zaakUrl)`,
which POSTs a `zaakinformatieobjecten` (`{ZrcBaseUrl}`) per document that has a `DrcUrl`.
Documents uploaded before a zaak existed (or under a config gap) have no `DrcUrl` yet and
are silently skipped — same "nothing extra to link" behaviour as the local source.
`ZgwHttpClient` (shared GET/POST-with-bearer-JWT plumbing) was factored out of
`OpenZaakZaakSource` once `OpenZaakDocumentSource` needed the identical boilerplate.
ponytail shortcut: `vertrouwelijkheidaanduiding` is hardcoded to `"openbaar"` — a per-category
confidentiality level would matter for production but isn't needed to prove the seam.
## The ZGW client (`backend/src/BigRegister.Api/Zgw/`) ## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs - `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs
(`ZrcBaseUrl`, `ZtcBaseUrl`), `ClientId`, `Secret`, `UserId`, `UserRepresentation`. The five (`ZrcBaseUrl`, `ZtcBaseUrl`, `DrcBaseUrl`), `ClientId`, `Secret`, `UserId`,
ZGW APIs are separate base URLs; slice 1 needs only Zaken (ZRC) + Catalogi (ZTC). `UserRepresentation`. The five ZGW APIs are separate base URLs; slices 1–3 need Zaken (ZRC),
Catalogi (ZTC), and Documenten (DRC).
- `ZgwTokenProvider.cs` — mints an **HS256 JWT per call** (`iss`/`client_id`/`iat`/`user_id`/ - `ZgwTokenProvider.cs` — mints an **HS256 JWT per call** (`iss`/`client_id`/`iat`/`user_id`/
`user_representation`). No refresh flow — OpenZaak expires tokens 1h past `iat`, so per-call `user_representation`). No refresh flow — OpenZaak expires tokens 1h past `iat`, so per-call
minting is the recommended pattern. Hand-rolled (no `Microsoft.IdentityModel.*` dependency). minting is the recommended pattern. Hand-rolled (no `Microsoft.IdentityModel.*` dependency).
- `ZgwHttpClient.cs` — shared GET/POST-with-bearer-JWT plumbing used by both
`OpenZaakZaakSource` and `OpenZaakDocumentSource`.
- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is - `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is
where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a
human label (the cross-service join). human label (the cross-service join).
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves + - `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`. caches zaaktype labels, attaches `Authorization: Bearer <jwt>`.
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
## The five ZGW APIs (context for later slices) ## The five ZGW APIs (context for later slices)
@@ -84,22 +121,24 @@ a production arc needs retry/reconciliation or an outbox before trusting this du
## How to add the next slice ## How to add the next slice
1. **Read** — extend `IZaakSource` (or add a sibling interface, e.g. `IDocumentSource`) with 1. **Read** — extend `IZaakSource` (or add a sibling interface, like `IDocumentSource`, WP-51)
the new operation; implement it on both `LocalZaakSource` and the OpenZaak source. Keep the with the new operation; implement it on both the local store and the OpenZaak source. Keep
return type the existing DTO so the FE never changes. the return type the existing DTO so the FE never changes.
2. **Write** (create-zaak, WP-50) — a create needs a `zaaktype` URL from Catalogi (OpenZaak 2. **Write** (create-zaak WP-50, DRC upload/link WP-51) — a create/upload needs a type URL
validates it by fetching), then usually a follow-up `status` + `rol`. Route it through the from Catalogi (OpenZaak validates it by fetching), then usually a follow-up call (`status` +
existing submit/mutation seam. `rol` for a zaak; `zaakinformatieobject` for a document). Route it through the existing
submit/mutation seam.
3. **Enforce server-side** for anything the FE gates — a config value the FE echoes is never 3. **Enforce server-side** for anything the FE gates — a config value the FE echoes is never
the authority (ADR-0001). the authority (ADR-0001).
## Coupling ## Coupling
Low and one-directional. Consumer coupling is near zero — `IZaakSource` is injected at one Low and one-directional. Consumer coupling is near zero — `IZaakSource`/`IDocumentSource` are
endpoint, and the FE is fully decoupled by the DTO. The producer side is contained in `Zgw/`: each injected at one endpoint, and the FE is fully decoupled by the DTO. The producer side is
add a slice by adding a source method + a mapper case, not by touching the FE or the contract. contained in `Zgw/`: add a slice by adding a source method + a mapper case, not by touching the
Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` — make the cases read FE or the contract. Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` (and
path async if OpenZaak becomes the default. its `OpenZaakDocumentSource` sibling) — make the read/write paths async if OpenZaak becomes the
default.
## Config ## Config
@@ -109,6 +148,7 @@ path async if OpenZaak becomes the default.
"Enabled": true, "Enabled": true,
"ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1", "ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1",
"ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1", "ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1",
"DrcBaseUrl": "https://open-zaak.example/documenten/api/v1",
"ClientId": "big-register", "Secret": "<from a secret store>", "ClientId": "big-register", "Secret": "<from a secret store>",
"UserId": "<session user>", "UserRepresentation": "<session name>", "UserId": "<session user>", "UserRepresentation": "<session name>",
// WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map. // WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map.
@@ -117,6 +157,11 @@ path async if OpenZaak becomes the default.
"registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>", "registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
"herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>", "herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
"intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>" "intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>"
},
// WP-51 (Documenten): upload category → informatieobjecttype URL map.
"InformatieobjecttypeUrls": {
"identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>",
"diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>"
} }
} }
``` ```
@@ -153,17 +198,18 @@ Principles this demonstrates:
comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be
complete on day one, but its shortcuts should be visible. complete on day one, but its shortcuts should be visible.
Caveat: `IZaakSource` now covers the cases **read + create** path (WP-49/50). Other BFF Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50) and `IDocumentSource`
endpoints still read `SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not covers **upload + zaak-link** (WP-51). Other BFF endpoints still read `SeedData`/static stores
yet swappable. That is the WP-51/52 roadmap, plus the two cross-cutting WPs the arc needs for directly — ACL-ready (the DTO seam exists) but not yet swappable. That is the WP-52 roadmap
production: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN (notificaties), plus the two cross-cutting WPs the arc needs for production: **WP-53** (a real
is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a
is fixture/mock-tested against no live instance). docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested
against no live instance).
## See also ## See also
- [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision. - [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision.
- [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change. - [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change.
- [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51/52 (CRUD arc), WP-53/54 (identity seam + integration harness). - [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53/54 (identity seam + integration harness).
- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs` — the seam. - `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs`/`Data/IDocumentSource.cs` — the seams.
- [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html). - [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).