Files
atomic-design-poc/docs/reference/openzaak-integration.md
T
ehoandClaude Sonnet 5 5807937229 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>
2026-07-29 20:54:31 +02:00

216 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenZaak / ZGW integration — how the BFF connects (& how to extend)
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
The Angular app only ever sees the BFF's decision DTOs (BFF-lite, ADR-0001). All ZGW
awkwardness — URL-as-identity, cross-service joins, JWT auth, pagination — is absorbed by the
.NET BFF. Flipping the data source from local SQLite to OpenZaak is a **backend config change**
with **zero frontend change and no api-client drift**.
## The seam (data source by config)
- `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).
- `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
`POST /applications/{id}/submit` already persists the aanvraag locally (`ApplicationStore.Submit`
— 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, 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`
(RSIN) from config, `identificatie` set to the **same** reference `ApplicationStore.Submit`
already generated — so the human-readable reference matches in both places, not two
independently-generated ones.
2. **POST status** (`{ZrcBaseUrl}/statussen`) — `statustype` resolved via a Catalogi GET
(`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened.
3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET
(`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn`
set to the aanvraag's owner (BSN) — the current stand-in for real identity (WP-53).
The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the
same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak
(`ZgwZaakMapper.ToCreatedStatusDto`).
ponytail shortcuts, marked at the call sites: (a) "first statustype/roltype Catalogi returns"
rather than a fully-configured per-type map — fine while a zaaktype has exactly one initial
status and initiator role; (b) no compensating transaction — if any ZGW call throws, the
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`, `DrcBaseUrl`), `ClientId`, `Secret`, `UserId`,
`UserRepresentation`. The five ZGW APIs are separate base URLs; slices 13 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)
| API | Component | Used by |
| ------------ | --------- | --------------------------------------------------- |
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) |
| Documenten | DRC | WP-51 (upload + zaak↔document link) |
| Besluiten | BRC | later (formal decisions) |
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
## How to add the next slice
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`/`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
```jsonc
// appsettings.json — off by default (POC runs offline on the local store)
"Zgw": {
"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.
"Bronorganisatie": "<RSIN>", "VerantwoordelijkeOrganisatie": "<RSIN>",
"ZaaktypeUrls": {
"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>"
}
}
```
## Anti-corruption layer — two nested boundaries (what to learn)
This setup is an anti-corruption layer (ACL) **twice over**, and seeing them as a pair is the
lesson worth taking away:
1. **The BFF guards everything against upstream systems.** OpenZaak's foreign model —
URL-as-identity, a `zaaktype` that is a URL _into another service_, `{count,next,previous,
results}` pagination, HS256 JWT auth — never leaves the BFF. `ZgwZaakMapper` translates it
into the BFF's own `ApplicationSummaryDto`; `IZaakSource` makes the boundary swappable
(`LocalZaakSource` vs `OpenZaakZaakSource` return the _same_ DTO).
2. **The Angular app guards itself against the BFF.** `infrastructure/` is the only layer that
touches the network (lint-enforced); every response crosses a `parse*` (`Result`) trust
boundary + a `toDomain` mapper before any domain/UI code sees it (ADR-0001, ARCHITECTURE §6).
The DTO at `/api/v1` is the membrane between them — which is why wiring OpenZaak touched **zero
frontend code and produced zero api-client drift**. That was the proof the ACL held.
Principles this demonstrates:
- **An ACL is a _mapping_, not a passthrough.** A DTO that is the upstream shape renamed is
corruption with extra steps; the valuable ACLs here (`ZgwZaakMapper`, the `parse*`/`toDomain`
pairs) actively translate a foreign model into a local one.
- **Put the ACL where trust changes, and make it the _only_ place.** One choke point per
boundary — the `Zgw/` folder + `IZaakSource` server-side, `infrastructure/` client-side.
- **Decision DTOs and the ACL are complementary.** BFF-lite (server decides, FE renders) is an
ACL against _business-rule_ drift, layered on the ACL against _data-shape_ drift.
- **A real seam is swap-testable offline.** Because the ACL returns a stable DTO, the ZGW
client is unit-testable with fixtures + a stub handler — no live OpenZaak.
- **Mark the honest edges.** The `ponytail:` sync-over-async note and the "coarse status map"
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` 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 (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).