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:
@@ -1,10 +1,12 @@
|
||||
# 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
|
||||
frontend stays unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md);
|
||||
this page is _how the seam is built and how to add the next slice_. Built in
|
||||
[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken) and
|
||||
[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (the first write: create-zaak).
|
||||
How the BFF sources (and now creates) cases, and uploads/links documents, against a real
|
||||
**OpenZaak** (ZGW APIs) while the frontend stays unchanged. For the _why_, see
|
||||
[ADR-0005](architecture/0005-openzaak-behind-bff.md); this page is _how the seam is built and
|
||||
how to add the next slice_. Built in
|
||||
[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
|
||||
|
||||
@@ -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
|
||||
`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`
|
||||
(offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit
|
||||
endpoint already computed locally — no external call.
|
||||
- `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`.
|
||||
`CreateZaak` posts a Zaak, then a Status, then a Rol (see below).
|
||||
- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient<IZaakSource, OpenZaakZaakSource>()
|
||||
else AddSingleton<IZaakSource, LocalZaakSource>()`. The `/admin/cases` GET and the
|
||||
`/applications/{id}/submit` POST both resolve `IZaakSource` from DI — routes + DTOs
|
||||
untouched either way.
|
||||
- `Data/IDocumentSource.cs` — the documents seam (WP-51), sibling of `IZaakSource`: `Upload`
|
||||
and `LinkToZaak`. `Data/LocalDocumentSource.cs` is the same `DocumentStore.Add`/`Link` calls
|
||||
the upload/submit endpoints used to make inline; `Zgw/OpenZaakDocumentSource.cs` also
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
just asks the seam for `(Referentie, Status)` and returns exactly that in the unchanged
|
||||
`SubmitApplicationResponse`. Under the default (local) source this returns precisely what was
|
||||
just computed; under OpenZaak, three calls happen in order:
|
||||
just asks the seam for `(Referentie, Status, ZaakUrl)` and returns the first two, unchanged, in
|
||||
`SubmitApplicationResponse` (`ZaakUrl` is persisted via `ApplicationStore.SetZaakUrl` for
|
||||
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]`
|
||||
(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;
|
||||
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/`)
|
||||
|
||||
- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs
|
||||
(`ZrcBaseUrl`, `ZtcBaseUrl`), `ClientId`, `Secret`, `UserId`, `UserRepresentation`. The five
|
||||
ZGW APIs are separate base URLs; slice 1 needs only Zaken (ZRC) + Catalogi (ZTC).
|
||||
(`ZrcBaseUrl`, `ZtcBaseUrl`, `DrcBaseUrl`), `ClientId`, `Secret`, `UserId`,
|
||||
`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`/
|
||||
`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).
|
||||
- `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
|
||||
where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a
|
||||
human label (the cross-service join).
|
||||
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
|
||||
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)
|
||||
|
||||
@@ -84,22 +121,24 @@ a production arc needs retry/reconciliation or an outbox before trusting this du
|
||||
|
||||
## How to add the next slice
|
||||
|
||||
1. **Read** — extend `IZaakSource` (or add a sibling interface, e.g. `IDocumentSource`) with
|
||||
the new operation; implement it on both `LocalZaakSource` and the OpenZaak source. Keep 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
|
||||
validates it by fetching), then usually a follow-up `status` + `rol`. Route it through the
|
||||
existing submit/mutation seam.
|
||||
1. **Read** — extend `IZaakSource` (or add a sibling interface, like `IDocumentSource`, WP-51)
|
||||
with the new operation; implement it on both the local store and the OpenZaak source. Keep
|
||||
the return type the existing DTO so the FE never changes.
|
||||
2. **Write** (create-zaak WP-50, DRC upload/link WP-51) — a create/upload needs a type URL
|
||||
from Catalogi (OpenZaak validates it by fetching), then usually a follow-up call (`status` +
|
||||
`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
|
||||
the authority (ADR-0001).
|
||||
|
||||
## Coupling
|
||||
|
||||
Low and one-directional. Consumer coupling is near zero — `IZaakSource` is injected at one
|
||||
endpoint, and the FE is fully decoupled by the DTO. The producer side is contained in `Zgw/`:
|
||||
add a slice by adding a source method + a mapper case, not by touching the FE or the contract.
|
||||
Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` — make the cases read
|
||||
path async if OpenZaak becomes the default.
|
||||
Low and one-directional. Consumer coupling is near zero — `IZaakSource`/`IDocumentSource` are
|
||||
each injected at one endpoint, and the FE is fully decoupled by the DTO. The producer side is
|
||||
contained in `Zgw/`: add a slice by adding a source method + a mapper case, not by touching the
|
||||
FE or the contract. Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` (and
|
||||
its `OpenZaakDocumentSource` sibling) — make the read/write paths async if OpenZaak becomes the
|
||||
default.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -109,6 +148,7 @@ path async if OpenZaak becomes the default.
|
||||
"Enabled": true,
|
||||
"ZrcBaseUrl": "https://open-zaak.example/zaken/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>",
|
||||
"UserId": "<session user>", "UserRepresentation": "<session name>",
|
||||
// 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>",
|
||||
"herregistratie": "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
|
||||
complete on day one, but its shortcuts should be visible.
|
||||
|
||||
Caveat: `IZaakSource` now covers the cases **read + create** path (WP-49/50). Other BFF
|
||||
endpoints still read `SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not
|
||||
yet swappable. That is the WP-51/52 roadmap, plus the two cross-cutting WPs the arc needs for
|
||||
production: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN
|
||||
is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything
|
||||
is fixture/mock-tested against no live instance).
|
||||
Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50) and `IDocumentSource`
|
||||
covers **upload + zaak-link** (WP-51). Other BFF endpoints still read `SeedData`/static stores
|
||||
directly — ACL-ready (the DTO seam exists) but not yet swappable. That is the WP-52 roadmap
|
||||
(notificaties), plus the two cross-cutting WPs the arc needs for production: **WP-53** (a real
|
||||
per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a
|
||||
docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested
|
||||
against no live instance).
|
||||
|
||||
## See also
|
||||
|
||||
- [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.
|
||||
- [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).
|
||||
- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs` — the seam.
|
||||
- [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`/`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).
|
||||
|
||||
Reference in New Issue
Block a user